diff --git a/pkg/cmd/plugin/install.go b/pkg/cmd/plugin/install.go index 4afca97d..f8c3e442 100644 --- a/pkg/cmd/plugin/install.go +++ b/pkg/cmd/plugin/install.go @@ -24,6 +24,9 @@ type InstallCmd struct { cfg *config.Config Cmd *cobra.Command fs afero.Fs + + archiveURL string + archivePath string } // NewInstallCmd creates a command for installing plugins @@ -34,13 +37,16 @@ func NewInstallCmd(config *config.Config) *InstallCmd { ic.Cmd = &cobra.Command{ Use: "install", - Args: validators.ExactArgs(1), + Args: validators.MaximumNArgs(1), Short: "Install a Stripe CLI plugin", Long: `Install a Stripe CLI plugin. To download a specific version, run stripe install [plugin_name]@[version]. By default, the most recent version will be installed.`, RunE: ic.runInstallCmd, } + ic.Cmd.Flags().StringVar(&ic.archiveURL, "archive-url", "", "Install a plugin by an archive URL") + ic.Cmd.Flags().StringVar(&ic.archivePath, "archive", "", "Install a plugin by an archive path") + return ic } @@ -58,15 +64,8 @@ func parseInstallArg(arg string) (string, string) { return plugin, version } -func (ic *InstallCmd) runInstallCmd(cmd *cobra.Command, args []string) error { - // Refresh the plugin before proceeding - err := plugins.RefreshPluginManifest(cmd.Context(), ic.cfg, ic.fs, stripe.DefaultAPIBaseURL) - - if err != nil { - return err - } - - pluginName, version := parseInstallArg(args[0]) +func (ic *InstallCmd) installPluginByName(cmd *cobra.Command, arg string) error { + pluginName, version := parseInstallArg(arg) plugin, err := plugins.LookUpPlugin(cmd.Context(), ic.cfg, ic.fs, pluginName) if err != nil { @@ -85,12 +84,53 @@ func (ic *InstallCmd) runInstallCmd(cmd *cobra.Command, args []string) error { err = plugin.Install(ctx, ic.cfg, ic.fs, version, stripe.DefaultAPIBaseURL) + return err +} + +func (ic *InstallCmd) installPluginByArchive(cmd *cobra.Command) error { + if ic.archiveURL == "" && ic.archivePath == "" { + return fmt.Errorf("please provide the plugin name or the archive URL/path to install") + } + + if ic.archiveURL != "" { + err := plugins.FetchAndExtractRemoteArchive(cmd.Context(), ic.cfg, ic.archiveURL) + if err != nil { + return err + } + } else if ic.archivePath != "" { + err := plugins.ExtractLocalArchive(cmd.Context(), ic.cfg, ic.archivePath) + if err != nil { + return err + } + } + + return nil +} + +func (ic *InstallCmd) runInstallCmd(cmd *cobra.Command, args []string) error { + var err error + + if len(args) == 0 { + err = ic.installPluginByArchive(cmd) + } else { + // Refresh the plugin before proceeding + err = plugins.RefreshPluginManifest(cmd.Context(), ic.cfg, ic.fs, stripe.DefaultAPIBaseURL) + if err != nil { + return err + } + + err = ic.installPluginByName(cmd, args[0]) + if err != nil { + return err + } + } + if err == nil { color := ansi.Color(os.Stdout) fmt.Println(color.Green("✔ installation complete.")) } - return err + return nil } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { diff --git a/pkg/plugins/plugin.go b/pkg/plugins/plugin.go index 6d043d40..33462bbd 100644 --- a/pkg/plugins/plugin.go +++ b/pkg/plugins/plugin.go @@ -33,11 +33,11 @@ var ( // Plugin contains the plugin properties type Plugin struct { - Shortname string - Shortdesc string - Binary string + Shortname string `toml:"Shortname"` + Shortdesc string `toml:"Shortdesc"` + Binary string `toml:"Binary"` Releases []Release `toml:"Release"` - MagicCookieValue string + MagicCookieValue string `toml:"MagicCookieValue"` } // PluginList contains a list of plugins @@ -47,10 +47,11 @@ type PluginList struct { // Release is the type that holds release data for a specific build of a plugin type Release struct { - Arch string - OS string - Version string - Sum string + Arch string `toml:"Arch"` + OS string `toml:"OS"` + Version string `toml:"Version"` + Sum string `toml:"Sum"` + Unmanaged bool `toml:"Unmanaged"` } // getPluginInterface computes the correct metadata needed for starting the hcplugin client @@ -250,6 +251,20 @@ func (p *Plugin) Uninstall(ctx context.Context, config config.IConfig, fs afero. } func (p *Plugin) downloadAndSavePlugin(config config.IConfig, pluginDownloadURL string, fs afero.Fs, version string) error { + body, err := FetchRemoteResource(pluginDownloadURL) + if err != nil { + return err + } + + err = p.verifychecksumAndSavePlugin(body, config, fs, version) + if err != nil { + return err + } + + return nil +} + +func (p *Plugin) verifychecksumAndSavePlugin(pluginData []byte, config config.IConfig, fs afero.Fs, version string) error { logger := log.WithFields(log.Fields{ "prefix": "plugins.plugin.Install", }) @@ -260,30 +275,21 @@ func (p *Plugin) downloadAndSavePlugin(config config.IConfig, pluginDownloadURL logger.Debugf("installing %s to %s...", p.Shortname, pluginFilePath) - body, err := FetchRemoteResource(pluginDownloadURL) - - if err != nil { - return err - } - - reader := bytes.NewReader(body) - - err = p.verifyChecksum(reader, version) + reader := bytes.NewReader(pluginData) + err := p.verifyChecksum(reader, version) if err != nil { logger.Debug("could not match checksum of plugin") return err } err = fs.MkdirAll(pluginDir, 0755) - if err != nil { logger.Debugf("could not create plugin directory: %s", pluginDir) return err } - err = afero.WriteFile(fs, pluginFilePath, body, 0755) - + err = afero.WriteFile(fs, pluginFilePath, pluginData, 0755) if err != nil { logger.Debug("could not save plugin to disk") return err diff --git a/pkg/plugins/utilities.go b/pkg/plugins/utilities.go index ec96e077..cd93d587 100644 --- a/pkg/plugins/utilities.go +++ b/pkg/plugins/utilities.go @@ -1,14 +1,19 @@ package plugins import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "fmt" + "io" "io/ioutil" "net/http" "net/http/httptrace" "os" "path/filepath" "runtime" + "strings" log "github.com/sirupsen/logrus" @@ -18,6 +23,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" + "github.com/stripe/stripe-cli/pkg/ansi" "github.com/stripe/stripe-cli/pkg/config" "github.com/stripe/stripe-cli/pkg/requests" "github.com/stripe/stripe-cli/pkg/stripe" @@ -126,6 +132,75 @@ func RefreshPluginManifest(ctx context.Context, config config.IConfig, fs afero. return nil } +// AddEntryToPluginManifest update plugins.toml with a new release version +func AddEntryToPluginManifest(ctx context.Context, config config.IConfig, fs afero.Fs, entry Plugin) error { + currentPluginList, err := GetPluginList(ctx, config, fs) + if err != nil { + return nil + } + + foundPlugin := false + for i, plugin := range currentPluginList.Plugins { + // already a plugin in the manfest with the same name, so use this instead of making a new one + if plugin.Shortname == entry.Shortname { + // plugin already installed. append a new release version + foundPlugin = true + + entryRelease := entry.Releases[0] + foundRelease := false + for _, release := range plugin.Releases { + if release.Version == entryRelease.Version && release.Unmanaged == entryRelease.Unmanaged { + foundRelease = true + break + } + } + + if !foundRelease { + currentPluginList.Plugins[i].Releases = append(currentPluginList.Plugins[i].Releases, entry.Releases[0]) + } + break + } + } + + if !foundPlugin { + // plugin does not exist. add a new plugin with a new dev release + currentPluginList.Plugins = append(currentPluginList.Plugins, entry) + } + + buf := new(bytes.Buffer) + err = toml.NewEncoder(buf).Encode(currentPluginList) + if err != nil { + return err + } + + configPath := config.GetConfigFolder(os.Getenv("XDG_CONFIG_HOME")) + pluginManifestPath := filepath.Join(configPath, "plugins.toml") + err = os.WriteFile(pluginManifestPath, buf.Bytes(), 0644) + if err != nil { + return err + } + + config.InitConfig() + installedList := config.GetInstalledPlugins() + + // check for plugin already in list (ie. in the case of an upgrade) + isInstalled := false + for _, name := range installedList { + if name == entry.Shortname { + isInstalled = true + } + } + + if !isInstalled { + installedList = append(installedList, entry.Shortname) + } + + // sync list of installed plugins to file + config.WriteConfigField("installed_plugins", installedList) + + return nil +} + // FetchRemoteResource returns the remote resource body func FetchRemoteResource(url string) ([]byte, error) { t := &requests.TracedTransport{} @@ -162,6 +237,141 @@ func FetchRemoteResource(url string) ([]byte, error) { return body, nil } +// ExtractLocalArchive extracts the local tarball body +func ExtractLocalArchive(ctx context.Context, config config.IConfig, source string) error { + color := ansi.Color(os.Stdout) + fmt.Println(color.Yellow(fmt.Sprintf("extracting tarball at %s...", source))) + + f, err := os.Open(source) + if err != nil { + return err + } + defer f.Close() + + gzf, err := gzip.NewReader(f) + if err != nil { + return err + } + + tarReader := tar.NewReader(gzf) + err = extractFromArchive(ctx, config, tarReader) + if err != nil { + return err + } + + return nil +} + +// FetchAndExtractRemoteArchive fetches and extracts the remote tarball body +func FetchAndExtractRemoteArchive(ctx context.Context, config config.IConfig, url string) error { + color := ansi.Color(os.Stdout) + fmt.Println(color.Yellow(fmt.Sprintf("fetching tarball at %s...", url))) + + t := &requests.TracedTransport{} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + trace := &httptrace.ClientTrace{ + GotConn: t.GotConn, + DNSDone: t.DNSDone, + } + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + client := &http.Client{Transport: t} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + archive, err := gzip.NewReader(resp.Body) + if err != nil { + return err + } + + defer archive.Close() + + tarReader := tar.NewReader(archive) + err = extractFromArchive(ctx, config, tarReader) + if err != nil { + return err + } + + return nil +} + +// extractFromArchive extracts plugin tarball +func extractFromArchive(ctx context.Context, config config.IConfig, tarReader *tar.Reader) error { + var manifest PluginList + var pluginData []byte + fs := afero.NewOsFs() + color := ansi.Color(os.Stdout) + extractedPluginName := "" + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } else if err != nil { + return err + } + + name := header.Name + + switch header.Typeflag { + case tar.TypeDir: + continue + case tar.TypeReg: + if name == "manifest.toml" { + tomlBytes, _ := ioutil.ReadAll(tarReader) + err = toml.Unmarshal(tomlBytes, &manifest) + if err != nil { + return err + } + + fmt.Println(color.Green(fmt.Sprintf("✔ extracted manifest '%s'", name))) + } else if strings.Contains(name, "stripe-cli-") { + extractedPluginName = name + pluginData, _ = ioutil.ReadAll(tarReader) + fmt.Println(color.Green(fmt.Sprintf("✔ extracted plugin '%s'", name))) + } + + default: + return fmt.Errorf("unrecognized file type for file %s: %c", name, header.Typeflag) + } + } + + // update plugin manifest and config manifest + if len(manifest.Plugins) == 1 && len(manifest.Plugins[0].Releases) == 1 && len(pluginData) > 0 { + plugin := manifest.Plugins[0] + plugin.Releases[0].Unmanaged = true + + if extractedPluginName != plugin.Shortname { + return fmt.Errorf( + "extracted plugin '%s' does not match the plugin '%s' in the manifest", + extractedPluginName, + plugin.Shortname) + } + + err := AddEntryToPluginManifest(ctx, config, fs, plugin) + if err != nil { + return err + } + + err = plugin.verifychecksumAndSavePlugin(pluginData, config, fs, plugin.Releases[0].Version) + if err != nil { + return err + } + } else { + return fmt.Errorf("missing required manifest.toml or plugin in the archive") + } + + return nil +} + // CleanupAllClients tears down and disconnects all "managed" plugin clients func CleanupAllClients() { log.Debug("Tearing down plugin before exit")