github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/release/download/download.go (about) 1 package download 2 3 import ( 4 "errors" 5 "fmt" 6 "io" 7 "mime" 8 "net/http" 9 "os" 10 "path/filepath" 11 "regexp" 12 13 "github.com/MakeNowJust/heredoc" 14 "github.com/ungtb10d/cli/v2/api" 15 "github.com/ungtb10d/cli/v2/internal/ghrepo" 16 "github.com/ungtb10d/cli/v2/pkg/cmd/release/shared" 17 "github.com/ungtb10d/cli/v2/pkg/cmdutil" 18 "github.com/ungtb10d/cli/v2/pkg/iostreams" 19 "github.com/spf13/cobra" 20 ) 21 22 type DownloadOptions struct { 23 HttpClient func() (*http.Client, error) 24 IO *iostreams.IOStreams 25 BaseRepo func() (ghrepo.Interface, error) 26 OverwriteExisting bool 27 SkipExisting bool 28 TagName string 29 FilePatterns []string 30 Destination string 31 OutputFile string 32 33 // maximum number of simultaneous downloads 34 Concurrency int 35 36 ArchiveType string 37 } 38 39 func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command { 40 opts := &DownloadOptions{ 41 IO: f.IOStreams, 42 HttpClient: f.HttpClient, 43 } 44 45 cmd := &cobra.Command{ 46 Use: "download [<tag>]", 47 Short: "Download release assets", 48 Long: heredoc.Doc(` 49 Download assets from a GitHub release. 50 51 Without an explicit tag name argument, assets are downloaded from the 52 latest release in the project. In this case, '--pattern' is required. 53 `), 54 Example: heredoc.Doc(` 55 # download all assets from a specific release 56 $ gh release download v1.2.3 57 58 # download only Debian packages for the latest release 59 $ gh release download --pattern '*.deb' 60 61 # specify multiple file patterns 62 $ gh release download -p '*.deb' -p '*.rpm' 63 64 # download the archive of the source code for a release 65 $ gh release download v1.2.3 --archive=zip 66 `), 67 Args: cobra.MaximumNArgs(1), 68 RunE: func(cmd *cobra.Command, args []string) error { 69 // support `-R, --repo` override 70 opts.BaseRepo = f.BaseRepo 71 72 if len(args) == 0 { 73 if len(opts.FilePatterns) == 0 && opts.ArchiveType == "" { 74 return cmdutil.FlagErrorf("`--pattern` or `--archive` is required when downloading the latest release") 75 } 76 } else { 77 opts.TagName = args[0] 78 } 79 80 if err := cmdutil.MutuallyExclusive("specify only one of `--clobber` or `--skip-existing`", opts.OverwriteExisting, opts.SkipExisting); err != nil { 81 return err 82 } 83 84 if err := cmdutil.MutuallyExclusive("specify only one of `--dir` or `--output`", opts.Destination != ".", opts.OutputFile != ""); err != nil { 85 return err 86 } 87 88 // check archive type option validity 89 if err := checkArchiveTypeOption(opts); err != nil { 90 return err 91 } 92 93 opts.Concurrency = 5 94 95 if runF != nil { 96 return runF(opts) 97 } 98 return downloadRun(opts) 99 }, 100 } 101 102 cmd.Flags().StringVarP(&opts.OutputFile, "output", "O", "", "The `file` to write a single asset to (use \"-\" to write to standard output)") 103 cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The `directory` to download files into") 104 cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only assets that match a glob pattern") 105 cmd.Flags().StringVarP(&opts.ArchiveType, "archive", "A", "", "Download the source code archive in the specified `format` (zip or tar.gz)") 106 cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing files of the same name") 107 cmd.Flags().BoolVar(&opts.SkipExisting, "skip-existing", false, "Skip downloading when files of the same name exist") 108 109 return cmd 110 } 111 112 func checkArchiveTypeOption(opts *DownloadOptions) error { 113 if len(opts.ArchiveType) == 0 { 114 return nil 115 } 116 117 if err := cmdutil.MutuallyExclusive( 118 "specify only one of '--pattern' or '--archive'", 119 true, // ArchiveType len > 0 120 len(opts.FilePatterns) > 0, 121 ); err != nil { 122 return err 123 } 124 125 if opts.ArchiveType != "zip" && opts.ArchiveType != "tar.gz" { 126 return cmdutil.FlagErrorf("the value for `--archive` must be one of \"zip\" or \"tar.gz\"") 127 } 128 return nil 129 } 130 131 func downloadRun(opts *DownloadOptions) error { 132 httpClient, err := opts.HttpClient() 133 if err != nil { 134 return err 135 } 136 137 baseRepo, err := opts.BaseRepo() 138 if err != nil { 139 return err 140 } 141 142 opts.IO.StartProgressIndicator() 143 defer opts.IO.StopProgressIndicator() 144 145 var release *shared.Release 146 if opts.TagName == "" { 147 release, err = shared.FetchLatestRelease(httpClient, baseRepo) 148 if err != nil { 149 return err 150 } 151 } else { 152 release, err = shared.FetchRelease(httpClient, baseRepo, opts.TagName) 153 if err != nil { 154 return err 155 } 156 } 157 158 var toDownload []shared.ReleaseAsset 159 isArchive := false 160 if opts.ArchiveType != "" { 161 var archiveURL = release.ZipballURL 162 if opts.ArchiveType == "tar.gz" { 163 archiveURL = release.TarballURL 164 } 165 // create pseudo-Asset with no name and pointing to ZipBallURL or TarBallURL 166 toDownload = append(toDownload, shared.ReleaseAsset{APIURL: archiveURL}) 167 isArchive = true 168 } else { 169 for _, a := range release.Assets { 170 if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) { 171 continue 172 } 173 toDownload = append(toDownload, a) 174 } 175 } 176 177 if len(toDownload) == 0 { 178 if len(release.Assets) > 0 { 179 return errors.New("no assets match the file pattern") 180 } 181 return errors.New("no assets to download") 182 } 183 184 if len(toDownload) > 1 && opts.OutputFile != "" { 185 return fmt.Errorf("unable to write more than one asset with `--output`, got %d assets", len(toDownload)) 186 } 187 188 dest := destinationWriter{ 189 file: opts.OutputFile, 190 dir: opts.Destination, 191 skipExisting: opts.SkipExisting, 192 overwrite: opts.OverwriteExisting, 193 stdout: opts.IO.Out, 194 } 195 196 return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive) 197 } 198 199 func matchAny(patterns []string, name string) bool { 200 for _, p := range patterns { 201 if isMatch, err := filepath.Match(p, name); err == nil && isMatch { 202 return true 203 } 204 } 205 return false 206 } 207 208 func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool) error { 209 if numWorkers == 0 { 210 return errors.New("the number of concurrent workers needs to be greater than 0") 211 } 212 213 jobs := make(chan shared.ReleaseAsset, len(toDownload)) 214 results := make(chan error, len(toDownload)) 215 216 if len(toDownload) < numWorkers { 217 numWorkers = len(toDownload) 218 } 219 220 for w := 1; w <= numWorkers; w++ { 221 go func() { 222 for a := range jobs { 223 results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive) 224 } 225 }() 226 } 227 228 for _, a := range toDownload { 229 jobs <- a 230 } 231 close(jobs) 232 233 var downloadError error 234 for i := 0; i < len(toDownload); i++ { 235 if err := <-results; err != nil && !errors.Is(err, errSkipped) { 236 downloadError = err 237 } 238 } 239 240 return downloadError 241 } 242 243 func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL, fileName string, isArchive bool) error { 244 if err := dest.Check(fileName); err != nil { 245 return err 246 } 247 248 req, err := http.NewRequest("GET", assetURL, nil) 249 if err != nil { 250 return err 251 } 252 253 req.Header.Set("Accept", "application/octet-stream") 254 if isArchive { 255 // adding application/json to Accept header due to a bug in the zipball/tarball API endpoint that makes it mandatory 256 req.Header.Set("Accept", "application/octet-stream, application/json") 257 258 // override HTTP redirect logic to avoid "legacy" Codeload resources 259 oldClient := *httpClient 260 httpClient = &oldClient 261 httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { 262 if len(via) == 1 { 263 req.URL.Path = removeLegacyFromCodeloadPath(req.URL.Path) 264 } 265 return nil 266 } 267 } 268 269 resp, err := httpClient.Do(req) 270 if err != nil { 271 return err 272 } 273 defer resp.Body.Close() 274 275 if resp.StatusCode > 299 { 276 return api.HandleHTTPError(resp) 277 } 278 279 if len(fileName) == 0 { 280 contentDisposition := resp.Header.Get("Content-Disposition") 281 282 _, params, err := mime.ParseMediaType(contentDisposition) 283 if err != nil { 284 return fmt.Errorf("unable to parse file name of archive: %w", err) 285 } 286 if serverFileName, ok := params["filename"]; ok { 287 fileName = serverFileName 288 } else { 289 return errors.New("unable to determine file name of archive") 290 } 291 } 292 293 return dest.Copy(fileName, resp.Body) 294 } 295 296 var codeloadLegacyRE = regexp.MustCompile(`^(/[^/]+/[^/]+/)legacy\.`) 297 298 // removeLegacyFromCodeloadPath converts URLs for "legacy" Codeload archives into ones that match the format 299 // when you choose to download "Source code (zip/tar.gz)" from a tagged release on the web. The legacy URLs 300 // look like this: 301 // 302 // https://codeload.github.com/OWNER/REPO/legacy.zip/refs/tags/TAGNAME 303 // 304 // Removing the "legacy." part results in a valid Codeload URL for our desired archive format. 305 func removeLegacyFromCodeloadPath(p string) string { 306 return codeloadLegacyRE.ReplaceAllString(p, "$1") 307 } 308 309 var errSkipped = errors.New("skipped") 310 311 // destinationWriter handles writing content into destination files 312 type destinationWriter struct { 313 file string 314 dir string 315 skipExisting bool 316 overwrite bool 317 stdout io.Writer 318 } 319 320 func (w destinationWriter) makePath(name string) string { 321 if w.file == "" { 322 return filepath.Join(w.dir, name) 323 } 324 return w.file 325 } 326 327 // Check returns an error if a file already exists at destination 328 func (w destinationWriter) Check(name string) error { 329 if name == "" { 330 // skip check as file name will only be known after the API request 331 return nil 332 } 333 fp := w.makePath(name) 334 if fp == "-" { 335 // writing to stdout should always proceed 336 return nil 337 } 338 return w.check(fp) 339 } 340 341 func (w destinationWriter) check(fp string) error { 342 if _, err := os.Stat(fp); err == nil { 343 if w.skipExisting { 344 return errSkipped 345 } 346 if !w.overwrite { 347 return fmt.Errorf( 348 "%s already exists (use `--clobber` to overwrite file or `--skip-existing` to skip file)", 349 fp, 350 ) 351 } 352 } 353 return nil 354 } 355 356 // Copy writes the data from r into a file specified by name 357 func (w destinationWriter) Copy(name string, r io.Reader) error { 358 fp := w.makePath(name) 359 if fp == "-" { 360 _, err := io.Copy(w.stdout, r) 361 return err 362 } 363 if err := w.check(fp); err != nil { 364 return err 365 } 366 367 if dir := filepath.Dir(fp); dir != "." { 368 if err := os.MkdirAll(dir, 0755); err != nil { 369 return err 370 } 371 } 372 373 f, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE, 0644) 374 if err != nil { 375 return err 376 } 377 defer f.Close() 378 379 _, err = io.Copy(f, r) 380 return err 381 }