diff --git a/CHANGELOG.md b/CHANGELOG.md index d623fe41f7..9074ee2da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ "You know what they say. Fool me once, strike one, but fool me twice... strike three." — Michael Scott +## Unreleased + +- feat: Expose downloadBinary function to install binary (#1817) + ## 1.76.0 ### Various fixes & improvements diff --git a/js/index.d.ts b/js/index.d.ts index f96e6121cc..5b4bafc19e 100644 --- a/js/index.d.ts +++ b/js/index.d.ts @@ -222,6 +222,13 @@ declare module '@sentry/cli' { public static getVersion(): string public static getPath(): string + + /** + * Downloads the CLI binary. + * @returns {Promise} + */ + static downloadBinary(logger: { log(...args: unknown[]): void }): Promise; + public execute(args: string[], live: boolean): Promise } } diff --git a/js/index.js b/js/index.js index 07b0180de5..eeca2251e5 100644 --- a/js/index.js +++ b/js/index.js @@ -3,6 +3,7 @@ const pkgInfo = require('../package.json'); const helper = require('./helper'); const Releases = require('./releases'); +const install = require('./install'); /** * Interface to and wrapper around the `sentry-cli` executable. @@ -54,6 +55,15 @@ class SentryCli { return helper.getPath(); } + /** + * Downloads the CLI binary. + * @param {any} [configFile] Optional logger to log installation information. Defaults to printing to the terminal. + * @returns {Promise} + */ + static downloadBinary(logger) { + return install.downloadBinary(logger); + } + /** * See {helper.execute} docs. * @param {string[]} args Command line arguments passed to `sentry-cli`. diff --git a/js/install.js b/js/install.js new file mode 100755 index 0000000000..4486ba4415 --- /dev/null +++ b/js/install.js @@ -0,0 +1,305 @@ +'use strict'; + +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const zlib = require('zlib'); +const stream = require('stream'); +const process = require('process'); + +const HttpsProxyAgent = require('https-proxy-agent'); +const fetch = require('node-fetch'); +const ProgressBar = require('progress'); +const Proxy = require('proxy-from-env'); +// NOTE: Can be dropped in favor of `fs.mkdirSync(path, { recursive: true })` once we stop supporting Node 8.x +const mkdirp = require('mkdirp'); +const which = require('which'); + +const helper = require('./helper'); +const pkgInfo = require('../package.json'); +const Logger = require('./logger'); + +function getLogStream(defaultStream) { + const logStream = process.env.SENTRYCLI_LOG_STREAM || defaultStream; + + if (logStream === 'stdout') { + return process.stdout; + } + + if (logStream === 'stderr') { + return process.stderr; + } + + throw new Error( + `Incorrect SENTRYCLI_LOG_STREAM env variable. Possible values: 'stdout' | 'stderr'` + ); +} + +const ttyLogger = new Logger(getLogStream('stderr')); + +const CDN_URL = + process.env.SENTRYCLI_LOCAL_CDNURL || + process.env.npm_config_sentrycli_cdnurl || + process.env.SENTRYCLI_CDNURL || + 'https://downloads.sentry-cdn.com/sentry-cli'; + +function shouldRenderProgressBar() { + const silentFlag = process.argv.some((v) => v === '--silent'); + const silentConfig = process.env.npm_config_loglevel === 'silent'; + // Leave `SENTRY_NO_PROGRESS_BAR` for backwards compatibility + const silentEnv = process.env.SENTRYCLI_NO_PROGRESS_BAR || process.env.SENTRY_NO_PROGRESS_BAR; + const ciEnv = process.env.CI === 'true'; + // If any of possible options is set, skip rendering of progress bar + return !(silentFlag || silentConfig || silentEnv || ciEnv); +} + +function getDownloadUrl(platform, arch) { + const releasesUrl = `${CDN_URL}/${pkgInfo.version}/sentry-cli`; + let archString = ''; + switch (arch) { + case 'x64': + archString = 'x86_64'; + break; + case 'x86': + case 'ia32': + archString = 'i686'; + break; + case 'arm64': + archString = 'aarch64'; + break; + case 'arm': + archString = 'armv7'; + break; + default: + archString = arch; + } + switch (platform) { + case 'darwin': + return `${releasesUrl}-Darwin-universal`; + case 'win32': + return `${releasesUrl}-Windows-${archString}.exe`; + case 'linux': + case 'freebsd': + return `${releasesUrl}-Linux-${archString}`; + default: + return null; + } +} + +function createProgressBar(name, total) { + const incorrectTotal = typeof total !== 'number' || Number.isNaN(total); + + if (incorrectTotal || !shouldRenderProgressBar()) { + return { + tick: () => {}, + }; + } + + const logStream = getLogStream('stdout'); + + if (logStream.isTTY) { + return new ProgressBar(`fetching ${name} :bar :percent :etas`, { + complete: '█', + incomplete: '░', + width: 20, + total, + }); + } + + let pct = null; + let current = 0; + return { + tick: (length) => { + current += length; + const next = Math.round((current / total) * 100); + if (next > pct) { + pct = next; + logStream.write(`fetching ${name} ${pct}%\n`); + } + }, + }; +} + +function npmCache() { + const env = process.env; + return ( + env.npm_config_cache || + env.npm_config_cache_folder || + env.npm_config_yarn_offline_mirror || + (env.APPDATA ? path.join(env.APPDATA, 'npm-cache') : path.join(os.homedir(), '.npm')) + ); +} + +function getCachedPath(url) { + const digest = crypto.createHash('md5').update(url).digest('hex').slice(0, 6); + + return path.join( + npmCache(), + 'sentry-cli', + `${digest}-${path.basename(url).replace(/[^a-zA-Z0-9.]+/g, '-')}` + ); +} + +function getTempFile(cached) { + return `${cached}.${process.pid}-${Math.random().toString(16).slice(2)}.tmp`; +} + +function validateChecksum(tempPath, name, logger) { + let storedHash; + try { + const checksums = fs.readFileSync(path.join(__dirname, '../checksums.txt'), 'utf8'); + const entries = checksums.split('\n'); + for (let i = 0; i < entries.length; i++) { + const [key, value] = entries[i].split('='); + if (key === name) { + storedHash = value; + break; + } + } + } catch (e) { + logger.log( + 'Checksums are generated when the package is published to npm. They are not available directly in the source repository. Skipping validation.' + ); + return; + } + + if (!storedHash) { + logger.log(`Checksum for ${name} not found, skipping validation.`); + return; + } + + const currentHash = crypto.createHash('sha256').update(fs.readFileSync(tempPath)).digest('hex'); + + if (storedHash !== currentHash) { + fs.unlinkSync(tempPath); + throw new Error( + `Checksum validation for ${name} failed.\nExpected: ${storedHash}\nReceived: ${currentHash}` + ); + } else { + logger.log('Checksum validation passed.'); + } +} + +function checkVersion() { + return helper.execute(['--version']).then((output) => { + const version = output.replace('sentry-cli ', '').trim(); + const expected = process.env.SENTRYCLI_LOCAL_CDNURL ? 'DEV' : pkgInfo.version; + if (version !== expected) { + throw new Error(`Unexpected sentry-cli version "${version}", expected "${expected}"`); + } + }); +} + +function downloadBinary(logger = ttyLogger) { + if (process.env.SENTRYCLI_SKIP_DOWNLOAD === '1') { + logger.log(`Skipping download because SENTRYCLI_SKIP_DOWNLOAD=1 detected.`); + return; + } + + const arch = os.arch(); + const platform = os.platform(); + const outputPath = helper.getPath(); + + if (process.env.SENTRYCLI_USE_LOCAL === '1') { + try { + const binPath = which.sync('sentry-cli'); + logger.log(`Using local binary: ${binPath}`); + fs.copyFileSync(binPath, outputPath); + return Promise.resolve(); + } catch (e) { + throw new Error( + 'Configured installation of local binary, but it was not found.' + + 'Make sure that `sentry-cli` executable is available in your $PATH or disable SENTRYCLI_USE_LOCAL env variable.' + ); + } + } + + const downloadUrl = getDownloadUrl(platform, arch); + if (!downloadUrl) { + return Promise.reject(new Error(`Unsupported target ${platform}-${arch}`)); + } + + const cachedPath = getCachedPath(downloadUrl); + if (fs.existsSync(cachedPath)) { + logger.log(`Using cached binary: ${cachedPath}`); + fs.copyFileSync(cachedPath, outputPath); + return Promise.resolve(); + } + + const proxyUrl = Proxy.getProxyForUrl(downloadUrl); + const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : null; + + logger.log(`Downloading from ${downloadUrl}`); + + if (proxyUrl) { + logger.log(`Using proxy URL: ${proxyUrl}`); + } + + return fetch(downloadUrl, { + agent, + compress: false, + headers: { + 'accept-encoding': 'gzip, deflate, br', + }, + redirect: 'follow', + }) + .then((response) => { + if (!response.ok) { + throw new Error( + `Unable to download sentry-cli binary from ${downloadUrl}.\nServer returned ${response.status}: ${response.statusText}.` + ); + } + + const contentEncoding = response.headers.get('content-encoding'); + let decompressor; + if (/\bgzip\b/.test(contentEncoding)) { + decompressor = zlib.createGunzip(); + } else if (/\bdeflate\b/.test(contentEncoding)) { + decompressor = zlib.createInflate(); + } else if (/\bbr\b/.test(contentEncoding)) { + decompressor = zlib.createBrotliDecompress(); + } else { + decompressor = new stream.PassThrough(); + } + const name = downloadUrl.match(/.*\/(.*?)$/)[1]; + const total = parseInt(response.headers.get('content-length'), 10); + const progressBar = createProgressBar(name, total); + const tempPath = getTempFile(cachedPath); + mkdirp.sync(path.dirname(tempPath)); + + return new Promise((resolve, reject) => { + response.body + .on('error', (e) => reject(e)) + .on('data', (chunk) => progressBar.tick(chunk.length)) + .pipe(decompressor) + .pipe(fs.createWriteStream(tempPath, { mode: '0755' })) + .on('error', (e) => reject(e)) + .on('close', () => resolve()); + }).then(() => { + if (process.env.SENTRYCLI_SKIP_CHECKSUM_VALIDATION !== '1') { + validateChecksum(tempPath, name, logger); + } + fs.copyFileSync(tempPath, cachedPath); + fs.copyFileSync(tempPath, outputPath); + fs.unlinkSync(tempPath); + }); + }) + .then(() => { + return checkVersion(); + }) + .catch((error) => { + if (error instanceof fetch.FetchError) { + throw new Error( + `Unable to download sentry-cli binary from ${downloadUrl}.\nError code: ${error.code}` + ); + } else { + throw error; + } + }); +} + +module.exports = { + downloadBinary, +}; diff --git a/scripts/install.js b/scripts/install.js index 5694668e7e..5768aa0352 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -2,306 +2,10 @@ 'use strict'; -const fs = require('fs'); const http = require('http'); -const os = require('os'); +const fs = require('fs'); const path = require('path'); -const crypto = require('crypto'); -const zlib = require('zlib'); -const stream = require('stream'); -const process = require('process'); - -const HttpsProxyAgent = require('https-proxy-agent'); -const fetch = require('node-fetch'); -const ProgressBar = require('progress'); -const Proxy = require('proxy-from-env'); -// NOTE: Can be dropped in favor of `fs.mkdirSync(path, { recursive: true })` once we stop supporting Node 8.x -const mkdirp = require('mkdirp'); -const which = require('which'); - -const helper = require('../js/helper'); -const pkgInfo = require('../package.json'); -const Logger = require('../js/logger'); - -function getLogStream(defaultStream) { - const logStream = process.env.SENTRYCLI_LOG_STREAM || defaultStream; - - if (logStream === 'stdout') { - return process.stdout; - } - - if (logStream === 'stderr') { - return process.stderr; - } - - throw new Error( - `Incorrect SENTRYCLI_LOG_STREAM env variable. Possible values: 'stdout' | 'stderr'` - ); -} - -const logger = new Logger(getLogStream('stderr')); - -const CDN_URL = - process.env.SENTRYCLI_LOCAL_CDNURL || - process.env.npm_config_sentrycli_cdnurl || - process.env.SENTRYCLI_CDNURL || - 'https://downloads.sentry-cdn.com/sentry-cli'; - -function shouldRenderProgressBar() { - const silentFlag = process.argv.some(v => v === '--silent'); - const silentConfig = process.env.npm_config_loglevel === 'silent'; - // Leave `SENTRY_NO_PROGRESS_BAR` for backwards compatibility - const silentEnv = process.env.SENTRYCLI_NO_PROGRESS_BAR || process.env.SENTRY_NO_PROGRESS_BAR; - const ciEnv = process.env.CI === 'true'; - // If any of possible options is set, skip rendering of progress bar - return !(silentFlag || silentConfig || silentEnv || ciEnv); -} - -function getDownloadUrl(platform, arch) { - const releasesUrl = `${CDN_URL}/${pkgInfo.version}/sentry-cli`; - let archString = ''; - switch (arch) { - case 'x64': - archString = 'x86_64'; - break; - case 'x86': - case 'ia32': - archString = 'i686'; - break; - case 'arm64': - archString = 'aarch64'; - break; - case 'arm': - archString = 'armv7'; - break; - default: - archString = arch; - } - switch (platform) { - case 'darwin': - return `${releasesUrl}-Darwin-universal`; - case 'win32': - return `${releasesUrl}-Windows-${archString}.exe`; - case 'linux': - case 'freebsd': - return `${releasesUrl}-Linux-${archString}`; - default: - return null; - } -} - -function createProgressBar(name, total) { - const incorrectTotal = typeof total !== 'number' || Number.isNaN(total); - - if (incorrectTotal || !shouldRenderProgressBar()) { - return { - tick: () => {}, - }; - } - - const logStream = getLogStream('stdout'); - - if (logStream.isTTY) { - return new ProgressBar(`fetching ${name} :bar :percent :etas`, { - complete: '█', - incomplete: '░', - width: 20, - total, - }); - } - - let pct = null; - let current = 0; - return { - tick: length => { - current += length; - const next = Math.round((current / total) * 100); - if (next > pct) { - pct = next; - logStream.write(`fetching ${name} ${pct}%\n`); - } - }, - }; -} - -function npmCache() { - const env = process.env; - return ( - env.npm_config_cache || - env.npm_config_cache_folder || - env.npm_config_yarn_offline_mirror || - (env.APPDATA ? path.join(env.APPDATA, 'npm-cache') : path.join(os.homedir(), '.npm')) - ); -} - -function getCachedPath(url) { - const digest = crypto - .createHash('md5') - .update(url) - .digest('hex') - .slice(0, 6); - - return path.join( - npmCache(), - 'sentry-cli', - `${digest}-${path.basename(url).replace(/[^a-zA-Z0-9.]+/g, '-')}` - ); -} - -function getTempFile(cached) { - return `${cached}.${process.pid}-${Math.random() - .toString(16) - .slice(2)}.tmp`; -} - -function validateChecksum(tempPath, name) { - let storedHash; - try { - const checksums = fs.readFileSync(path.join(__dirname, '../checksums.txt'), 'utf8'); - const entries = checksums.split('\n'); - for (let i = 0; i < entries.length; i++) { - const [key, value] = entries[i].split('='); - if (key === name) { - storedHash = value; - break; - } - } - } catch (e) { - logger.log( - 'Checksums are generated when the package is published to npm. They are not available directly in the source repository. Skipping validation.' - ); - return; - } - - if (!storedHash) { - logger.log(`Checksum for ${name} not found, skipping validation.`); - return; - } - - const currentHash = crypto - .createHash('sha256') - .update(fs.readFileSync(tempPath)) - .digest('hex'); - - if (storedHash !== currentHash) { - fs.unlinkSync(tempPath); - throw new Error( - `Checksum validation for ${name} failed.\nExpected: ${storedHash}\nReceived: ${currentHash}` - ); - } else { - logger.log('Checksum validation passed.'); - } -} - -function downloadBinary() { - const arch = os.arch(); - const platform = os.platform(); - const outputPath = helper.getPath(); - - if (process.env.SENTRYCLI_USE_LOCAL === '1') { - try { - const binPath = which.sync('sentry-cli'); - logger.log(`Using local binary: ${binPath}`); - fs.copyFileSync(binPath, outputPath); - return Promise.resolve(); - } catch (e) { - throw new Error( - 'Configured installation of local binary, but it was not found.' + - 'Make sure that `sentry-cli` executable is available in your $PATH or disable SENTRYCLI_USE_LOCAL env variable.' - ); - } - } - - const downloadUrl = getDownloadUrl(platform, arch); - if (!downloadUrl) { - return Promise.reject(new Error(`Unsupported target ${platform}-${arch}`)); - } - - const cachedPath = getCachedPath(downloadUrl); - if (fs.existsSync(cachedPath)) { - logger.log(`Using cached binary: ${cachedPath}`); - fs.copyFileSync(cachedPath, outputPath); - return Promise.resolve(); - } - - const proxyUrl = Proxy.getProxyForUrl(downloadUrl); - const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : null; - - logger.log(`Downloading from ${downloadUrl}`); - - if (proxyUrl) { - logger.log(`Using proxy URL: ${proxyUrl}`); - } - - return fetch(downloadUrl, { - agent, - compress: false, - headers: { - 'accept-encoding': 'gzip, deflate, br', - }, - redirect: 'follow', - }) - .then(response => { - if (!response.ok) { - throw new Error( - `Unable to download sentry-cli binary from ${downloadUrl}.\nServer returned ${response.status}: ${response.statusText}.` - ); - } - - const contentEncoding = response.headers.get('content-encoding'); - let decompressor; - if (/\bgzip\b/.test(contentEncoding)) { - decompressor = zlib.createGunzip(); - } else if (/\bdeflate\b/.test(contentEncoding)) { - decompressor = zlib.createInflate(); - } else if (/\bbr\b/.test(contentEncoding)) { - decompressor = zlib.createBrotliDecompress(); - } else { - decompressor = new stream.PassThrough(); - } - const name = downloadUrl.match(/.*\/(.*?)$/)[1]; - const total = parseInt(response.headers.get('content-length'), 10); - const progressBar = createProgressBar(name, total); - const tempPath = getTempFile(cachedPath); - mkdirp.sync(path.dirname(tempPath)); - - return new Promise((resolve, reject) => { - response.body - .on('error', e => reject(e)) - .on('data', chunk => progressBar.tick(chunk.length)) - .pipe(decompressor) - .pipe(fs.createWriteStream(tempPath, { mode: '0755' })) - .on('error', e => reject(e)) - .on('close', () => resolve()); - }).then(() => { - if (process.env.SENTRYCLI_SKIP_CHECKSUM_VALIDATION !== '1') { - validateChecksum(tempPath, name); - } - fs.copyFileSync(tempPath, cachedPath); - fs.copyFileSync(tempPath, outputPath); - fs.unlinkSync(tempPath); - }); - }) - .catch(error => { - if (error instanceof fetch.FetchError) { - throw new Error( - `Unable to download sentry-cli binary from ${downloadUrl}.\nError code: ${error.code}` - ); - } else { - throw error; - } - }); -} - -function checkVersion() { - return helper.execute(['--version']).then(output => { - const version = output.replace('sentry-cli ', '').trim(); - const expected = process.env.SENTRYCLI_LOCAL_CDNURL ? 'DEV' : pkgInfo.version; - if (version !== expected) { - throw new Error(`Unexpected sentry-cli version "${version}", expected "${expected}"`); - } - }); -} +const { downloadBinary } = require('../js/install'); if (process.env.SENTRYCLI_LOCAL_CDNURL) { // For testing, mock the CDN by spawning a local server @@ -318,13 +22,7 @@ if (process.env.SENTRYCLI_LOCAL_CDNURL) { process.on('exit', () => server.close()); } -if (process.env.SENTRYCLI_SKIP_DOWNLOAD === '1') { - logger.log(`Skipping download because SENTRYCLI_SKIP_DOWNLOAD=1 detected.`); - process.exit(0); -} - downloadBinary() - .then(() => checkVersion()) .then(() => process.exit(0)) .catch(e => { // eslint-disable-next-line no-console