├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .yarn └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── bin └── fetch-github-release ├── eslint.config.js ├── jest.config.js ├── package.json ├── src ├── cli.ts ├── download.ts ├── downloadRelease.ts ├── getLatest.ts ├── getReleases.ts ├── index.ts ├── interfaces.ts └── rpad.ts ├── test ├── download-spec.ts ├── downloadRelease-spec.ts ├── extract-spec.ts ├── fixtures │ ├── file.txt │ ├── file.zip │ └── releases.json ├── getLatest-spec.ts ├── getReleases-spec.ts ├── index-spec.ts ├── rpad-spec.ts └── utils │ └── nockServer.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{js,ts,jsx,tsx,json,md}] 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.md] 19 | max_line_length = off 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: weekly 7 | time: '04:00' 8 | timezone: US/Arizona 9 | open-pull-requests-limit: 3 10 | assignees: 11 | - jsnoble 12 | - godber 13 | - busma13 14 | - sotojn 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | npm-publish: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | env: 13 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | # NOTE: Hard Coded Node Version 19 | node-version: '18.x' 20 | registry-url: 'https://registry.npmjs.org' 21 | - run: yarn && yarn setup 22 | - name: Retrieve version 23 | run : echo "NEW_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT 24 | id: version 25 | - run: 26 | echo "Check NPM Authentication" 27 | yarn npm whoami 28 | - run: yarn npm publish --access public 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Fetch-Github-Release Tests 2 | on: 3 | pull_request: 4 | branches: [ master ] 5 | 6 | jobs: 7 | verify-build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out code 11 | uses: actions/checkout@v4 12 | 13 | - name: Setup Node 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 18.x 17 | cache: 'yarn' 18 | 19 | - name: Install and build packages 20 | run: yarn && yarn setup 21 | 22 | - name: Lint codebase 23 | run: yarn lint 24 | 25 | run-tests: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | node-version: [18.x, 20.x, 22.x] 30 | steps: 31 | - name: Check out code 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup Node ${{ matrix.node-version }} 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | cache: 'yarn' 39 | 40 | - name: Install and build packages 41 | run: yarn && yarn setup 42 | 43 | - name: Test 44 | run: yarn test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.log 3 | node_modules 4 | lib 5 | dist 6 | tmp 7 | coverage 8 | 9 | # .yarn 10 | .yarn/install-state.gz -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | src 4 | test 5 | tmp 6 | coverage 7 | .vscode 8 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 4 | 5 | npmAuthToken: "${NODE_AUTH_TOKEN:-placeHolderToken}" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Stephan Florquin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Download Github Release 2 | 3 | A node module to download Github release assets. It will also uncompress zip 4 | files and skip downloading if a file already exists. 5 | 6 | ```bash 7 | $ fetch-github-release -s darwin-x64 electron electron 8 | Downloading electron/electron@v1.3.1... 9 | electron-v1.3.1-darwi... ▇▇▇▇▇---------------------------------------------------- 662.8s 10 | electron-v1.3.1-darwi... ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇--------- 13.4s 11 | electron-v1.3.1-darwi... ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇--- 3.6s 12 | ffmpeg-v1.3.1-darwin-... ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 0.0s 13 | ``` 14 | 15 | This is useful for instance if you have a project that depends on binaries 16 | released via Github. 17 | 18 | ## Command line 19 | 20 | ### Installation 21 | 22 | ```bash 23 | npm install -g @terascope/fetch-github-release 24 | # or 25 | yarn global add @terascope/fetch-github-release 26 | ``` 27 | 28 | ### Usage 29 | 30 | ```bash 31 | Usage: fetch-github-release [options] [outputdir] 32 | 33 | Options: 34 | -V, --version output the version number 35 | -p, --prerelease download prerelease 36 | -s, --search filter assets name 37 | -q, --quiet don't log to console 38 | -z, --zipped don't extract zip files 39 | -h, --help output usage information 40 | ``` 41 | 42 | ### Example 43 | 44 | Download `electron/electron` assets whose name contains `darwin-x64` to `/tmp`. 45 | 46 | ```bash 47 | fetch-github-release -s darwin-x64 electron electron /tmp 48 | ``` 49 | 50 | If you need to download assets from a private repository or you need to avoid rate limits, you can set the environment variable `GITHUB_TOKEN`. To generate a token go to your Github [settings](https://github.com/settings/tokens) and a token with `public_repo` or `repo` (for private repos) permissions. 51 | 52 | ## API 53 | 54 | ### Installation 55 | 56 | ```bash 57 | npm install --save @terascope/fetch-github-release 58 | # or 59 | yarn add @terascope/fetch-github-release 60 | ``` 61 | 62 | ### Usage 63 | 64 | ```javascript 65 | import { downloadRelease } from '@terascope/fetch-github-release'; 66 | 67 | const user = 'some user'; 68 | const repo = 'some repo'; 69 | const outputdir = 'some output directory'; 70 | const leaveZipped = false; 71 | const disableLogging = false; 72 | 73 | // Define a function to filter releases. 74 | function filterRelease(release) { 75 | // Filter out prereleases. 76 | return release.prerelease === false; 77 | } 78 | 79 | // Define a function to filter assets. 80 | function filterAsset(asset) { 81 | // Select assets that contain the string 'windows'. 82 | return asset.name.includes('windows'); 83 | } 84 | 85 | downloadRelease(user, repo, outputdir, filterRelease, filterAsset, leaveZipped, disableLogging) 86 | .then(function() { 87 | console.log('All done!'); 88 | }) 89 | .catch(function(err) { 90 | console.error(err.message); 91 | }); 92 | ``` 93 | 94 | `downloadRelease` returns an array of file paths to all of the files downloaded 95 | if called with `leaveZipped = true`. 96 | 97 | ## TODO 98 | 99 | - other compression formats 100 | - option to download specific release instead of latest? 101 | - option to download source? 102 | -------------------------------------------------------------------------------- /bin/fetch-github-release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from 'node:path'; 3 | import fs from 'node:fs'; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | const filename = fileURLToPath(import.meta.url); 7 | import(path.join(path.dirname(fs.realpathSync(filename)), '../dist/src/cli.js')); 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintConfig from '@terascope/eslint-config'; 2 | 3 | export default eslintConfig; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | verbose: true, 3 | testEnvironment: 'node', 4 | setupFilesAfterEnv: ['jest-extended/all'], 5 | collectCoverage: true, 6 | coverageReporters: ['json', 'lcov', 'text', 'html'], 7 | coverageDirectory: 'coverage', 8 | collectCoverageFrom: [ 9 | '/src/**/*.ts', 10 | '!/**/coverage/**', 11 | '!/**/*.d.ts', 12 | '!/**/dist/**', 13 | '!/**/coverage/**' 14 | ], 15 | testMatch: [ 16 | '/test/**/*-spec.{ts,js}', 17 | '/test/*-spec.{ts,js}', 18 | ], 19 | moduleNameMapper: { 20 | '^(\\.{1,2}/.*)\\.js$': '$1' 21 | }, 22 | preset: 'ts-jest', 23 | extensionsToTreatAsEsm: ['.ts'], 24 | globals: { 25 | 'ts-jest': { 26 | tsconfig: './tsconfig.json', 27 | diagnostics: true, 28 | useESM: true 29 | }, 30 | ignoreDirectories: ['dist'], 31 | availableExtensions: ['.js', '.ts', '.mjs'] 32 | }, 33 | testTimeout: 60 * 1000 34 | }; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@terascope/fetch-github-release", 3 | "publishConfig": { 4 | "access": "public" 5 | }, 6 | "version": "2.2.1", 7 | "description": "Download a specific release from github", 8 | "type": "module", 9 | "files": [ 10 | "dist/src/**/*", 11 | "bin/*" 12 | ], 13 | "main": "dist/src/index.js", 14 | "typings": "dist/src/index.d.ts", 15 | "scripts": { 16 | "build": "tsc --project tsconfig.json", 17 | "build:watch": "yarn build --watch", 18 | "lint": "eslint --ignore-pattern .gitignore", 19 | "lint:fix": "yarn lint --fix", 20 | "setup": "yarn && yarn build", 21 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 22 | "test:cov": "NODE_OPTIONS='--experimental-vm-modules' jest --collectCoverage", 23 | "test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage=false --notify --watch --onlyChanged", 24 | "test:debug": "NODE_OPTIONS='--experimental-vm-modules' env DEBUG=\"${DEBUG:-*teraslice*}\" jest --detectOpenHandles --coverage=false --runInBand", 25 | "check": "yarn run lint && yarn run test", 26 | "clean": "rimraf dist coverage", 27 | "prepublishOnly": "yarn run clean && yarn run build" 28 | }, 29 | "bin": "bin/fetch-github-release", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/terascope/fetch-github-release.git" 33 | }, 34 | "keywords": [ 35 | "github", 36 | "release", 37 | "download" 38 | ], 39 | "author": "Stephan Florquin", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/terascope/fetch-github-release/issues" 43 | }, 44 | "dependencies": { 45 | "extract-zip": "^2.0.1", 46 | "got": "14.4.7", 47 | "multi-progress": "^4.0.0", 48 | "progress": "^2.0.3", 49 | "yargs": "^18.0.0" 50 | }, 51 | "devDependencies": { 52 | "@terascope/eslint-config": "^1.1.17", 53 | "@types/jest": "^29.5.14", 54 | "@types/multi-progress": "^2.0.6", 55 | "@types/node": "^22.15.30", 56 | "@types/stream-buffers": "^3.0.7", 57 | "@types/tmp": "^0.2.6", 58 | "eslint": "^9.28.0", 59 | "jest": "^29.7.0", 60 | "jest-extended": "^6.0.0", 61 | "nock": "^14.0.5", 62 | "node-notifier": "^10.0.1", 63 | "rimraf": "^6.0.1", 64 | "stream-buffers": "^3.0.3", 65 | "tmp": "0.2.3", 66 | "ts-jest": "^29.3.4", 67 | "typescript": "^5.8.3" 68 | }, 69 | "engines": { 70 | "node": ">=18.0.0" 71 | }, 72 | "packageManager": "yarn@4.9.1" 73 | } 74 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | import { hideBin } from 'yargs/helpers'; 3 | import { downloadRelease } from './downloadRelease.js'; 4 | import { GithubRelease, GithubReleaseAsset } from './interfaces.js'; 5 | 6 | const command = await yargs(hideBin(process.argv)) 7 | .alias('h', 'help') 8 | .alias('v', 'version') 9 | .option('dry-run', { 10 | description: 'list metadata instead of downloading', 11 | type: 'boolean', 12 | alias: 'd', 13 | default: false 14 | }) 15 | .option('output', { 16 | description: 'return dry-run information in either text or json format', 17 | type: 'string', 18 | alias: 'o', 19 | default: 'text', 20 | choices: ['json', 'text'] 21 | }) 22 | .option('prerelease', { 23 | description: 'download prerelease', 24 | type: 'boolean', 25 | alias: 'p', 26 | default: false 27 | }) 28 | .option('search', { 29 | description: 'filter assets name, this can be regex', 30 | alias: 's', 31 | type: 'string', 32 | }) 33 | .option('quiet', { 34 | description: 'don\'t log to console, if dry-run is enabled, will only list release', 35 | type: 'boolean', 36 | alias: 'q', 37 | default: false 38 | }) 39 | .option('zipped', { 40 | description: 'don\'t extract zip files', 41 | type: 'boolean', 42 | alias: 'z', 43 | default: false 44 | }) 45 | .positional('user', { 46 | description: 'The Github user', 47 | type: 'string', 48 | }) 49 | .requiresArg('user') 50 | .positional('repo', { 51 | description: 'The Github repo', 52 | type: 'string', 53 | }) 54 | .requiresArg('repo') 55 | .positional('outputdir', { 56 | description: 'The directory to download the assets to', 57 | type: 'string', 58 | }) 59 | .argv; 60 | 61 | const user = command._[0]; 62 | const repo = command._[1]; 63 | const outputdir = command._[2] || process.cwd(); 64 | 65 | function filterRelease(release: GithubRelease): boolean { 66 | return release.draft === false && release.prerelease === !!command.prerelease; 67 | } 68 | 69 | function filterAsset(asset: GithubReleaseAsset): boolean { 70 | if (!command.search) { 71 | return true; 72 | } 73 | 74 | return new RegExp(command.search).test(asset.name); 75 | } 76 | 77 | downloadRelease( 78 | user as string, 79 | repo as string, 80 | outputdir as string, 81 | filterRelease, 82 | filterAsset, 83 | !!command.zipped, 84 | !!command.quiet, 85 | !!command.dryRun, 86 | command.output 87 | ) 88 | .catch((err) => { 89 | console.error(err); 90 | process.exitCode = 1; 91 | }) 92 | .finally(() => { 93 | process.exit(); 94 | }); 95 | -------------------------------------------------------------------------------- /src/download.ts: -------------------------------------------------------------------------------- 1 | import http from 'node:http'; 2 | import https from 'node:https'; 3 | import { Writable } from 'node:stream'; 4 | import URL from 'node:url'; 5 | 6 | const { GITHUB_TOKEN } = process.env; 7 | 8 | function getRequestOptions(urlString: string) { 9 | const url = URL.parse(urlString); 10 | const headers: Record = { 11 | Accept: 'application/octet-stream', 12 | 'User-Agent': '@terascope/fetch-github-release', 13 | }; 14 | 15 | if (GITHUB_TOKEN) { 16 | headers.Authorization = `token ${GITHUB_TOKEN}`; 17 | } 18 | 19 | return Object.assign({}, url, { headers }); 20 | } 21 | 22 | export function download( 23 | url: string, 24 | w: Writable, 25 | progress: (percentage: number) => void = () => {} 26 | ): Promise { 27 | return new Promise((resolve, reject) => { 28 | let protocol = /^https:/.exec(url) ? https : http; 29 | const options = getRequestOptions(url); 30 | 31 | progress(0); 32 | 33 | protocol 34 | .get(options, (res1) => { 35 | protocol = /^https:/.exec(res1.headers.location!) ? https : http; 36 | 37 | protocol 38 | .get(res1.headers.location!, (res2) => { 39 | const total = parseInt(res2.headers['content-length'] ?? '0', 10); 40 | let completed = 0; 41 | res2.pipe(w); 42 | res2.on('data', (data) => { 43 | completed += data.length; 44 | progress(completed / total); 45 | }); 46 | res2.on('progress', progress); 47 | res2.on('error', reject); 48 | res2.on('end', () => { 49 | w.on('close', () => { 50 | resolve(); 51 | }); 52 | }); 53 | }) 54 | .on('error', reject); 55 | }) 56 | .on('error', reject); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /src/downloadRelease.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import extract from 'extract-zip'; 5 | import MultiProgress from 'multi-progress'; 6 | import { getReleases } from './getReleases.js'; 7 | import { getLatest } from './getLatest.js'; 8 | import { download } from './download.js'; 9 | import { rpad } from './rpad.js'; 10 | import { 11 | GithubRelease, GithubReleaseAsset, ReleaseInfo 12 | } from './interfaces'; 13 | 14 | function pass() { 15 | return true; 16 | } 17 | 18 | /** 19 | * Download a specific github release 20 | * @param user The name of the github user or organization 21 | * @param repo The name of the github repository 22 | * @param outputDir The directory to write the release to 23 | * @param filterRelease Optionally filter the release 24 | * @param filterAsset Optionally filter the asset for a given release 25 | * @param leaveZipped Optionally leave the file zipped 26 | * @param leaveZipped Optionally disable logging for quiet output 27 | * @param dryRun Only return information on what would have been downloaded 28 | * @param output Returns dry-run information in either plaintext or json format 29 | */ 30 | export async function downloadRelease( 31 | user: string, 32 | repo: string, 33 | outputDir: string, 34 | filterRelease: (release: GithubRelease) => boolean = pass, 35 | filterAsset: (release: GithubReleaseAsset) => boolean = pass, 36 | leaveZipped = false, 37 | disableLogging = false, 38 | dryRun = false, 39 | output = 'text' 40 | ): Promise { 41 | if (!user) { 42 | throw new Error('Missing user argument'); 43 | } 44 | if (!repo) { 45 | throw new Error('Missing user argument'); 46 | } 47 | const bars = new MultiProgress(process.stderr); 48 | 49 | const releases = await getReleases(user, repo); 50 | const release = getLatest(releases, filterRelease, filterAsset); 51 | if (!release) { 52 | throw new Error( 53 | `Could not find a release for ${user}/${repo} (${os.platform()} ${os.arch()})` 54 | ); 55 | } 56 | 57 | if (!disableLogging && !dryRun) { 58 | console.error(`Downloading ${user}/${repo}@${release.tag_name}...`); 59 | } 60 | 61 | const promises = release.assets.map(async (asset): Promise => { 62 | let progress; 63 | 64 | if (dryRun) { 65 | return asset.name; 66 | } 67 | 68 | if (process.stdout.isTTY && !disableLogging) { 69 | const bar = bars.newBar(`${rpad(asset.name, 24)} :bar :etas`, { 70 | complete: '▇', 71 | incomplete: '-', 72 | width: process.stdout.columns - 36, 73 | total: 100 74 | }); 75 | progress = bar.update.bind(bar); 76 | } 77 | 78 | // eslint-disable-next-line no-param-reassign 79 | outputDir = path.isAbsolute(outputDir) ? outputDir : path.resolve(outputDir); 80 | if (!fs.existsSync(outputDir)) { 81 | fs.mkdirSync(outputDir); 82 | } 83 | 84 | if (!fs.statSync(outputDir).isDirectory()) { 85 | throw new Error(`Output path "${outputDir}" must be a directory`); 86 | } 87 | 88 | const destf = path.join(outputDir, asset.name); 89 | if (!fs.existsSync(destf)) { 90 | const dest = fs.createWriteStream(destf); 91 | 92 | await download(asset.url, dest, progress); 93 | if (!leaveZipped && /\.zip$/.exec(destf)) { 94 | await extract(destf, { 95 | dir: outputDir 96 | }); 97 | fs.unlinkSync(destf); 98 | } 99 | } 100 | return destf; 101 | }); 102 | 103 | if (dryRun) { 104 | // In case of dryrun, send an organized object 105 | const dryRunInfo: ReleaseInfo = { 106 | release: `${user}/${repo}@${release.tag_name}`, 107 | assetFileNames: await Promise.all(promises) 108 | }; 109 | 110 | /// Give only the release string in the case 111 | // that quiet is enabled 112 | if (disableLogging && output === 'text') { 113 | process.stdout.write(`${dryRunInfo.release}\n`); 114 | } else if (disableLogging && output === 'json') { 115 | process.stdout.write(`${JSON.stringify(dryRunInfo.release, null, 2)}\n`); 116 | } else { 117 | printDryRunInfo(dryRunInfo, output); 118 | } 119 | 120 | return Promise.resolve(dryRunInfo); 121 | } 122 | 123 | return Promise.all(promises); 124 | } 125 | 126 | /** 127 | * Prints dry-run info in plaintext or JSON format. 128 | * 129 | * @param {ReleaseInfo} data The release information to display. 130 | * @param {string} output The output format, either 'text' or 'json'. 131 | */ 132 | function printDryRunInfo(data: ReleaseInfo, output: string): void { 133 | if (output === 'text') { 134 | const prompt = [ 135 | `Release: ${data.release}\n`, 136 | 'The following files would have been downloaded:\n', 137 | ...data.assetFileNames.map((file) => `- ${file}`), 138 | ].join('\n'); 139 | process.stdout.write(`${prompt}\n`); 140 | } else if (output === 'json') { 141 | process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/getLatest.ts: -------------------------------------------------------------------------------- 1 | import { GithubRelease, GithubReleaseAsset } from './interfaces.js'; 2 | 3 | function pass() { 4 | return true; 5 | } 6 | 7 | export function getLatest( 8 | releases: GithubRelease[], 9 | filterRelease: (release: GithubRelease) => boolean = pass, 10 | filterAsset: (release: GithubReleaseAsset) => boolean = pass 11 | ): GithubRelease | null { 12 | if (!releases) { 13 | return null; 14 | } 15 | 16 | const filtered = releases.filter(filterRelease); 17 | 18 | if (!filtered.length) { 19 | return null; 20 | } 21 | 22 | for (const release of filtered) { 23 | const assets = release.assets.filter(filterAsset); 24 | 25 | if (assets.length) { 26 | return Object.assign({}, release, { assets }); 27 | } 28 | } 29 | 30 | return null; 31 | } 32 | -------------------------------------------------------------------------------- /src/getReleases.ts: -------------------------------------------------------------------------------- 1 | import got, { OptionsOfJSONResponseBody } from 'got'; 2 | import { GithubRelease } from './interfaces.js'; 3 | 4 | const { GITHUB_TOKEN } = process.env; 5 | 6 | export async function getReleases(user: string, repo: string): Promise { 7 | const url = `https://api.github.com/repos/${user}/${repo}/releases`; 8 | 9 | const requestConfig: OptionsOfJSONResponseBody = { 10 | headers: { 11 | 'User-Agent': '@terascope/fetch-github-release' 12 | } as Record, 13 | responseType: 'json' 14 | }; 15 | 16 | if (GITHUB_TOKEN) { 17 | requestConfig.headers!.Authorization = `token ${GITHUB_TOKEN}`; 18 | } 19 | 20 | const r = await got.get(url, requestConfig); 21 | return r.body; 22 | } 23 | 24 | export { HTTPError } from 'got'; 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { downloadRelease } from './downloadRelease.js'; 2 | import { HTTPError } from './getReleases.js'; 3 | 4 | export { downloadRelease, HTTPError }; 5 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://developer.github.com/v3/repos/releases/#get-a-single-release-asset 3 | */ 4 | export interface GithubRelease { 5 | id: number; 6 | url: string; 7 | tag_name: string; 8 | name: string; 9 | body: string; 10 | draft: boolean; 11 | prerelease: boolean; 12 | created_at: string; 13 | published_at: string; 14 | assets: GithubReleaseAsset[]; 15 | } 16 | 17 | /** 18 | * See https://developer.github.com/v3/repos/releases/#get-a-single-release-asset 19 | */ 20 | export interface GithubReleaseAsset { 21 | id: number; 22 | name: string; 23 | url: string; 24 | content_type: string; 25 | size: number; 26 | created_at: string; 27 | updated_at: string; 28 | } 29 | /** 30 | * JSON object for printing dry-run 31 | */ 32 | export interface ReleaseInfo { 33 | release: string; 34 | assetFileNames: string[]; 35 | } 36 | -------------------------------------------------------------------------------- /src/rpad.ts: -------------------------------------------------------------------------------- 1 | export function rpad(text: string, len: number): string { 2 | let t = text; 3 | 4 | if (t.length > len) { 5 | t = `${text.substr(0, len - 3)}...`; 6 | } 7 | 8 | return `${t}${new Array(len - t.length + 1).join(' ')}`; 9 | } 10 | -------------------------------------------------------------------------------- /test/download-spec.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import nock from 'nock'; 3 | import streamBuffers from 'stream-buffers'; 4 | import { download } from '../src/download.js'; 5 | import { nockServer, fileTxt } from './utils/nockServer.js'; 6 | 7 | describe('#download()', () => { 8 | beforeEach(nockServer); 9 | afterEach(() => nock.cleanAll()); 10 | 11 | it('downloads a file', async () => { 12 | const w = new streamBuffers.WritableStreamBuffer(); 13 | await download('https://api.github.com/files/file-darwin-amd64.txt', w); 14 | expect(w.getContentsAsString('utf8')).toEqual(fileTxt); 15 | }); 16 | 17 | it('calls progress', async () => { 18 | const w = new streamBuffers.WritableStreamBuffer(); 19 | const progress = jest.fn(); 20 | await download('https://api.github.com/files/file-darwin-amd64.zip', w, progress); 21 | expect(progress).toHaveBeenNthCalledWith(1, 0); 22 | expect(progress).toHaveBeenNthCalledWith(2, 1); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/downloadRelease-spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import nock from 'nock'; 4 | import tmp from 'tmp'; 5 | import { downloadRelease } from '../src/downloadRelease.js'; 6 | import { nockServer, fileTxt, fileZip } from './utils/nockServer.js'; 7 | import { GithubReleaseAsset } from '../src/interfaces.js'; 8 | 9 | describe('#downloadRelease()', () => { 10 | let tmpobj: tmp.DirResult; 11 | 12 | beforeEach(() => { 13 | nockServer(); 14 | tmpobj = tmp.dirSync({ unsafeCleanup: true }); 15 | }); 16 | afterEach(() => { 17 | nock.cleanAll(); 18 | tmpobj.removeCallback(); 19 | }); 20 | 21 | it('downloads a release', async () => { 22 | const check = (a: GithubReleaseAsset) => a.name.indexOf('darwin-amd64') >= 0; 23 | 24 | await downloadRelease('me', 'test', tmpobj.name, undefined, check, false, true); 25 | 26 | expect(fs.readFileSync(path.join(tmpobj.name, '/file/file.txt'), 'utf8')) 27 | .toEqual(fileTxt); 28 | expect(fs.readFileSync(path.join(tmpobj.name, '/file-darwin-amd64.txt'), 'utf8')) 29 | .toEqual(fileTxt); 30 | }); 31 | 32 | it('downloads a release (without unzipping it)', async () => { 33 | const check = (a: GithubReleaseAsset) => a.name.indexOf('darwin-amd64') >= 0; 34 | 35 | await downloadRelease('me', 'test', tmpobj.name, undefined, check, true, true); 36 | 37 | expect(fs.readFileSync(path.join(tmpobj.name, '/file-darwin-amd64.zip')).toString('hex')) 38 | .toEqual(fileZip.toString('hex')); 39 | expect(fs.readFileSync(path.join(tmpobj.name, '/file-darwin-amd64.txt'), 'utf8')) 40 | .toEqual(fileTxt); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/extract-spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import tmp from 'tmp'; 4 | import extract from 'extract-zip'; 5 | import { fileZip, fileTxt } from './utils/nockServer.js'; 6 | 7 | describe('#extract()', () => { 8 | let tmpobj: tmp.DirResult; 9 | 10 | beforeEach(() => { 11 | tmpobj = tmp.dirSync({ unsafeCleanup: true }); 12 | }); 13 | afterEach(() => tmpobj.removeCallback()); 14 | 15 | it('extracts a zip file', async () => { 16 | const fileZipPath = path.join(tmpobj.name, 'file.zip'); 17 | fs.writeFileSync(fileZipPath, fileZip); 18 | await extract(fileZipPath, { dir: tmpobj.name }); 19 | expect(fs.readFileSync(path.join(tmpobj.name, '/file/file.txt'), 'utf8')) 20 | .toEqual(fileTxt); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/fixtures/file.txt: -------------------------------------------------------------------------------- 1 | Hello, World! 2 | -------------------------------------------------------------------------------- /test/fixtures/file.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terascope/fetch-github-release/6c5517a3925b8a50754043398d64eced8216598c/test/fixtures/file.zip -------------------------------------------------------------------------------- /test/fixtures/releases.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "tag_name": "v0.2.0", 4 | "name": "Test release", 5 | "draft": true, 6 | "prerelease": false, 7 | "assets": [ 8 | { 9 | "name": "file-darwin-amd64.zip", 10 | "url": "https://api.github.com/files/file-darwin-amd64.zip" 11 | }, 12 | { 13 | "name": "file-windows-amd64.zip", 14 | "url": "https://api.github.com/files/file-windows-amd64.zip" 15 | }, 16 | { 17 | "name": "file-darwin-amd64.txt", 18 | "url": "https://api.github.com/files/file-darwin-amd64.txt" 19 | }, 20 | { 21 | "name": "file-windows-amd64.txt", 22 | "url": "https://api.github.com/files/file-windows-amd64.txt" 23 | } 24 | ] 25 | }, 26 | { 27 | "tag_name": "v0.2.0-alpha", 28 | "name": "Test prerelease", 29 | "draft": false, 30 | "prerelease": true, 31 | "assets": [ 32 | { 33 | "name": "file-darwin-amd64.zip", 34 | "url": "https://api.github.com/files/file-darwin-amd64.zip" 35 | }, 36 | { 37 | "name": "file-windows-amd64.zip", 38 | "url": "https://api.github.com/files/file-windows-amd64.zip" 39 | }, 40 | { 41 | "name": "file-darwin-amd64.txt", 42 | "url": "https://api.github.com/files/file-darwin-amd64.txt" 43 | }, 44 | { 45 | "name": "file-windows-amd64.txt", 46 | "url": "https://api.github.com/files/file-windows-amd64.txt" 47 | } 48 | ] 49 | }, 50 | { 51 | "tag_name": "v0.1.0", 52 | "name": "Test release", 53 | "draft": false, 54 | "prerelease": false, 55 | "assets": [ 56 | { 57 | "name": "file-darwin-amd64.zip", 58 | "url": "https://api.github.com/files/file-darwin-amd64.zip" 59 | }, 60 | { 61 | "name": "file-windows-amd64.zip", 62 | "url": "https://api.github.com/files/file-windows-amd64.zip" 63 | }, 64 | { 65 | "name": "file-darwin-amd64.txt", 66 | "url": "https://api.github.com/files/file-darwin-amd64.txt" 67 | }, 68 | { 69 | "name": "file-windows-amd64.txt", 70 | "url": "https://api.github.com/files/file-windows-amd64.txt" 71 | } 72 | ] 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /test/getLatest-spec.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | import { getLatest } from '../src/getLatest.js'; 3 | import { releasesJson } from './utils/nockServer.js'; 4 | 5 | describe('#getLatest()', () => { 6 | it('gets the latest release', () => { 7 | expect(getLatest(releasesJson)).toEqual(releasesJson[0]); 8 | }); 9 | 10 | it('filters releases', () => { 11 | expect(getLatest(releasesJson, (r) => !r.draft)).toEqual(releasesJson[1]); 12 | }); 13 | 14 | it('filters assets', () => { 15 | expect(getLatest(releasesJson, undefined, (a) => a.name.indexOf('windows-amd64') >= 0)) 16 | .toEqual(Object.assign({}, releasesJson[0], { 17 | assets: [releasesJson[0].assets[1], releasesJson[0].assets[3]] 18 | })); 19 | }); 20 | 21 | it('return null if there is no match', () => { 22 | expect(getLatest(releasesJson, () => false)).toBeNull(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/getReleases-spec.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { getReleases } from '../src/getReleases.js'; 3 | import { nockServer, releasesJson } from './utils/nockServer.js'; 4 | 5 | describe('#getReleases()', () => { 6 | beforeEach(nockServer); 7 | afterEach(() => nock.cleanAll()); 8 | 9 | it('gets the releases', async () => { 10 | const body = await getReleases('me', 'test'); 11 | expect(body).toEqual(releasesJson); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/index-spec.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | import { downloadRelease } from '../src/index.js'; 3 | 4 | describe('fetch-github-release', () => { 5 | it('should expose a function', () => { 6 | expect(downloadRelease).toBeFunction(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /test/rpad-spec.ts: -------------------------------------------------------------------------------- 1 | import { rpad } from '../src/rpad.js'; 2 | 3 | describe('#rpad()', () => { 4 | it('adds right padding to a string', () => { 5 | expect(rpad('test', 20)).toEqual('test '); 6 | }); 7 | 8 | it('shortens the string if it is too long', () => { 9 | expect(rpad('a very long string', 16)).toEqual('a very long s...'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/utils/nockServer.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import nock from 'nock'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 7 | export const releasesJson = JSON.parse( 8 | fs.readFileSync(path.resolve(dirname, '../fixtures/releases.json'), 'utf8') 9 | ); 10 | export const fileTxt = fs.readFileSync(path.resolve(dirname, '../fixtures/file.txt'), 'utf8'); 11 | export const fileZip = fs.readFileSync(path.resolve(dirname, '../fixtures/file.zip')); 12 | 13 | export function nockServer(): void { 14 | nock('https://api.github.com') 15 | .get('/repos/me/test/releases') 16 | .reply(200, releasesJson, { 'Content-Type': 'application/json' }) 17 | .get('/files/file-darwin-amd64.txt') 18 | .reply(302, {}, { Location: 'https://api.github.com/download/file.txt' }) 19 | .get('/files/file-windows-amd64.txt') 20 | .reply(302, {}, { Location: 'https://api.github.com/download/file.txt' }) 21 | .get('/files/file-darwin-amd64.zip') 22 | .reply(302, {}, { Location: 'https://api.github.com/download/file.zip' }) 23 | .get('/files/file-windows-amd64.zip') 24 | .reply(302, {}, { Location: 'https://api.github.com/download/file.zip' }) 25 | .get('/download/file.txt') 26 | .reply(200, fileTxt, { 'Content-Length': String(fileTxt.length) }) 27 | .get('/download/file.zip') 28 | .reply(200, fileZip, { 'Content-Length': String(fileZip.length) }); 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "dist", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "target": "ESNext", 8 | "skipLibCheck": true, 9 | "experimentalDecorators": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "sourceMap": true, 15 | "inlineSourceMap": false, 16 | "noImplicitReturns": true, 17 | "preserveConstEnums": true, 18 | "esModuleInterop": true, 19 | "resolveJsonModule": true, 20 | "types": ["jest", "jest-extended"], 21 | "paths": { 22 | "got": [ 23 | "./node_modules/got/dist/source/index.d.ts" 24 | ] 25 | } 26 | }, 27 | "include": [ 28 | "src/**/*", 29 | "test/**/*" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------