-
-
Notifications
You must be signed in to change notification settings - Fork 222
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Expose
downloadBinary
function to install binary (#1817)
- Loading branch information
Showing
5 changed files
with
328 additions
and
304 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}; |
Oops, something went wrong.