| const path = require('path') |
| const os = require('os') |
| const exec = require('child_process').exec |
| const localDevPort = process.env.SCARF_LOCAL_PORT |
| const https = localDevPort ? require('http') : require('https') |
| const fs = require('fs') |
| const fsAsync = fs.promises |
| const util = require('util') |
|
|
| const scarfHost = localDevPort ? 'localhost' : 'scarf.sh' |
| const scarfLibName = '@scarf/scarf' |
| const privatePackageRewrite = '@private/private' |
| const privateVersionRewrite = '0' |
|
|
| const rootPath = process.env.INIT_CWD |
|
|
| |
| function tmpFileName () { |
| |
| const username = os.userInfo().username |
| return path.join(os.tmpdir(), `scarf-js-history-${username}.log`) |
| } |
|
|
| |
| function dirName () { |
| return __dirname |
| } |
|
|
| function npmExecPath () { |
| return process.env.npm_execpath |
| } |
|
|
| const userMessageThrottleTime = 1000 * 60 |
|
|
| const execTimeout = 3000 |
|
|
| |
| |
| |
| const optedInLogRateLimitKey = 'optedInLastLog' |
| const optedOutLogRateLimitKey = 'optedOutLastLog' |
|
|
| const makeDefaultSettings = () => { |
| return { |
| defaultOptIn: true |
| } |
| } |
|
|
| function logIfVerbose (toLog, stream) { |
| if (process.env.SCARF_VERBOSE === 'true') { |
| (stream || console.log)(toLog) |
| } |
| } |
|
|
| |
| const userHasOptedOut = (rootPackage) => { |
| return (rootPackage && rootPackage.scarfSettings && rootPackage.scarfSettings.enabled === false) || |
| (process.env.SCARF_ANALYTICS === 'false' || process.env.SCARF_NO_ANALYTICS === 'true' || process.env.DO_NOT_TRACK === '1') |
| } |
|
|
| const userHasOptedIn = (rootPackage) => { |
| return (rootPackage && rootPackage.scarfSettings && rootPackage.scarfSettings.enabled) || process.env.SCARF_ANALYTICS === 'true' |
| } |
|
|
| |
| |
| |
| function allowTopLevel (rootPackage) { |
| return rootPackage && rootPackage.scarfSettings && rootPackage.scarfSettings.allowTopLevel |
| } |
|
|
| function skipTraversal (rootPackage) { |
| return rootPackage && rootPackage.scarfSettings && rootPackage.scarfSettings.skipTraversal |
| } |
|
|
| function parentIsRoot (dependencyToReport) { |
| const parent = dependencyToReport.parent |
| const rootPackage = dependencyToReport.rootPackage |
|
|
| return parent && rootPackage && parent.name === rootPackage.name && parent.version === rootPackage.version |
| } |
|
|
| function isTopLevel (dependencyToReport) { |
| return parentIsRoot(dependencyToReport) && !process.env.npm_config_global |
| } |
|
|
| function isGlobal (dependencyToReport) { |
| return parentIsRoot(dependencyToReport) && !!process.env.npm_config_global |
| } |
|
|
| function hashWithDefault (toHash, defaultReturn) { |
| let crypto |
| try { |
| crypto = require('crypto') |
| } catch (err) { |
| logIfVerbose('node crypto module unavailable') |
| } |
|
|
| if (crypto && toHash) { |
| return crypto.createHash('sha256').update(toHash, 'utf-8').digest('hex') |
| } else { |
| return defaultReturn |
| } |
| } |
|
|
| |
| function redactSensitivePackageInfo (dependencyInfo) { |
| if (dependencyInfo.grandparent && dependencyInfo.grandparent.name) { |
| dependencyInfo.grandparent.nameHash = hashWithDefault(dependencyInfo.grandparent.name, privatePackageRewrite) |
| dependencyInfo.grandparent.versionHash = hashWithDefault(dependencyInfo.grandparent.version, privateVersionRewrite) |
| } |
|
|
| if (dependencyInfo.rootPackage && dependencyInfo.rootPackage.name) { |
| dependencyInfo.rootPackage.nameHash = hashWithDefault(dependencyInfo.rootPackage.name, privatePackageRewrite) |
| dependencyInfo.rootPackage.versionHash = hashWithDefault(dependencyInfo.rootPackage.version, privateVersionRewrite) |
| } |
|
|
| delete (dependencyInfo.rootPackage.packageJsonPath) |
| delete (dependencyInfo.rootPackage.path) |
| delete (dependencyInfo.rootPackage.name) |
| delete (dependencyInfo.rootPackage.version) |
| delete (dependencyInfo.parent.path) |
| delete (dependencyInfo.scarf.path) |
| if (dependencyInfo.grandparent) { |
| delete (dependencyInfo.grandparent.path) |
| delete (dependencyInfo.grandparent.name) |
| delete (dependencyInfo.grandparent.version) |
| } |
| return dependencyInfo |
| } |
|
|
| |
| |
| |
| |
| |
| function isYarn () { |
| const execPath = module.exports.npmExecPath() || '' |
| return ['yarn', 'yarn.js', 'yarnpkg', 'yarn.cmd', 'yarnpkg.cmd'] |
| .some(packageManBinName => execPath.endsWith(packageManBinName)) |
| } |
|
|
| function processDependencyTreeOutput (resolve, reject) { |
| return function (error, stdout, stderr) { |
| if (error && !stdout) { |
| return reject(new Error(`Scarf received an error from npm -ls: ${error} | ${stderr}`)) |
| } |
|
|
| try { |
| const output = JSON.parse(stdout) |
|
|
| const depsToScarf = findScarfInFullDependencyTree(output).filter(depChain => depChain.length >= 2) |
| if (!depsToScarf.length) { |
| return reject(new Error('No Scarf parent package found')) |
| } |
| const rootPackageDetails = rootPackageDepInfo(output) |
|
|
| const dependencyInfo = depsToScarf.map(depChain => { |
| return { |
| scarf: depChain[depChain.length - 1], |
| parent: depChain[depChain.length - 2], |
| grandparent: depChain[depChain.length - 3], |
| rootPackage: rootPackageDetails, |
| anyInChainDisabled: depChain.some(dep => { |
| return (dep.scarfSettings || {}).enabled === false |
| }) |
| } |
| }) |
|
|
| dependencyInfo.forEach(d => { |
| d.parent.scarfSettings = Object.assign(makeDefaultSettings(), d.parent.scarfSettings || {}) |
| }) |
|
|
| |
| const dependencyToReport = dependencyInfo.find(dep => (dep.scarf.path === module.exports.dirName())) || dependencyInfo[0] |
| if (!dependencyToReport) { |
| return reject(new Error(`Couldn't find dependency info for path ${module.exports.dirName()}`)) |
| } |
|
|
| |
| |
| if (dependencyToReport.anyInChainDisabled && !userHasOptedIn(dependencyToReport.rootPackage)) { |
| return reject(new Error('Scarf has been disabled via a package.json in the dependency chain.')) |
| } |
|
|
| if (isTopLevel(dependencyToReport) && !isGlobal(dependencyToReport) && !allowTopLevel(rootPackageDetails)) { |
| return reject(new Error('The package depending on Scarf is the root package being installed, but Scarf is not configured to run in this case. To enable it, set `scarfSettings.allowTopLevel = true` in your package.json')) |
| } |
|
|
| return resolve(dependencyToReport) |
| } catch (err) { |
| logIfVerbose(err, console.error) |
| return reject(err) |
| } |
| } |
| } |
|
|
| function processGitRevParseOutput (resolve, reject) { |
| return function (error, stdout, stderr) { |
| if (error && !stdout) { |
| return reject(new Error(`Scarf received an error from git rev-parse: ${error} | ${stderr}`)) |
| } |
|
|
| const output = String(stdout).trim() |
|
|
| if (output.length > 0) { |
| return resolve(output) |
| } else { |
| return reject(new Error('Scarf did not receive usable output from git rev-parse')) |
| } |
| } |
| } |
|
|
| |
| |
| async function getDependencyInfo (packageJSONOverride) { |
| try { |
| const rootPackageJSON = require(packageJSONOverride || path.join(rootPath, 'package.json')) |
| const scarfPackageJSON = require(path.join(dirName(), 'package.json')) |
|
|
| if (skipTraversal(rootPackageJSON)) { |
| logIfVerbose('skipping dependency tree traversal') |
| const rootInfoToReport = { |
| name: rootPackageJSON.name, |
| version: rootPackageJSON.version, |
| scarfSettings: { ...makeDefaultSettings(), ...rootPackageJSON.scarfSettings } |
| } |
| const shallowDepInfo = { |
| scarf: { name: '@scarf/scarf', version: scarfPackageJSON.version }, |
| parent: { ...rootInfoToReport }, |
| rootPackage: { ...rootInfoToReport }, |
| anyInChainDisabled: false, |
| skippedTraversal: true |
| } |
| logIfVerbose(util.inspect(shallowDepInfo)) |
| return shallowDepInfo |
| } |
| } catch (err) { |
| logIfVerbose(err, console.error) |
| } |
|
|
| return new Promise((resolve, reject) => { |
| exec(`cd ${rootPath} && npm ls @scarf/scarf --json --long`, { timeout: execTimeout, maxBuffer: 1024 * 1024 * 1024 }, processDependencyTreeOutput(resolve, reject)) |
| }) |
| } |
|
|
| async function getGitShaFromRootPath () { |
| const promise = new Promise((resolve, reject) => { |
| exec(`cd ${rootPath} && git rev-parse HEAD`, { timeout: execTimeout, maxBuffer: 1024 * 1024 * 1024 }, processGitRevParseOutput(resolve, reject)) |
| }) |
| try { |
| return await promise |
| } catch (e) { |
| logIfVerbose(e) |
| return undefined |
| } |
| } |
|
|
| async function reportPostInstall () { |
| const scarfApiToken = process.env.SCARF_API_TOKEN |
|
|
| const dependencyInfo = await module.exports.getDependencyInfo() |
| logIfVerbose(dependencyInfo) |
| if (!dependencyInfo.parent || !dependencyInfo.parent.name) { |
| return Promise.reject(new Error('No parent found, nothing to report')) |
| } |
|
|
| if (parentIsRoot(dependencyInfo) && allowTopLevel(dependencyInfo.rootPackage)) { |
| const gitSha = await getGitShaFromRootPath() |
| logIfVerbose(`Injecting sha to parent: ${gitSha}`) |
| dependencyInfo.parent.gitSha = gitSha |
| } |
|
|
| const rootPackage = dependencyInfo.rootPackage |
|
|
| if (!userHasOptedIn(rootPackage) && isYarn()) { |
| return Promise.reject(new Error('Package manager is yarn. scarf-js is unable to inform user of analytics. Aborting.')) |
| } |
|
|
| await new Promise((resolve, reject) => { |
| if (dependencyInfo.parent.scarfSettings.defaultOptIn) { |
| if (userHasOptedOut(rootPackage)) { |
| return reject(new Error('User has opted out')) |
| } |
|
|
| if (!userHasOptedIn(rootPackage)) { |
| rateLimitedUserLog(optedInLogRateLimitKey, ` |
| The dependency '${dependencyInfo.parent.name}' is tracking installation |
| statistics using scarf-js (https://scarf.sh), which helps open-source developers |
| fund and maintain their projects. Scarf securely logs basic installation |
| details when this package is installed. The Scarf npm library is open source |
| and permissively licensed at https://github.com/scarf-sh/scarf-js. For more |
| details about your project's dependencies, try running 'npm ls'. To opt out of |
| analytics, set the environment variable 'SCARF_ANALYTICS=false'. |
| `) |
| } |
| resolve(dependencyInfo) |
| } else { |
| if (!userHasOptedIn(rootPackage)) { |
| if (!userHasOptedOut(rootPackage)) { |
| |
| |
| if (hasHitRateLimit(optedOutLogRateLimitKey, getRateLimitedLogHistory())) { |
| return reject(new Error('Analytics are opt-out by default, but rate limit already hit for prompting opt-in.')) |
| } |
| rateLimitedUserLog(optedOutLogRateLimitKey, ` |
| The dependency '${dependencyInfo.parent.name}' would like to track |
| installation statistics using scarf-js (https://scarf.sh), which helps |
| open-source developers fund and maintain their projects. Reporting is disabled |
| by default for this package. When enabled, Scarf securely logs basic |
| installation details when this package is installed. The Scarf npm library is |
| open source and permissively licensed at https://github.com/scarf-sh/scarf-js. |
| For more details about your project's dependencies, try running 'npm ls'. |
| ` |
| ) |
| const stdin = process.stdin |
| stdin.setEncoding('utf-8') |
|
|
| process.stdout.write(`Would you like to support ${dependencyInfo.parent.name} by sending analytics for this install? (y/N): `) |
|
|
| const timeout1 = setTimeout(() => { |
| console.log('') |
| console.log('No opt in received, skipping analytics') |
| reject(new Error('Timeout waiting for user opt in')) |
| }, 7000) |
|
|
| stdin.on('data', async function (data) { |
| clearTimeout(timeout1) |
| const enabled = data.trim().toLowerCase() === 'y' |
|
|
| const afterUserInput = (enabled, saved) => { |
| if (enabled) { |
| console.log('Thanks for enabling analytics!') |
| } |
|
|
| if (!saved) { |
| console.log('To prevent this message in the future, you can also set the `SCARF_ANALYTICS=true|false` environment variable') |
| } |
|
|
| if (enabled) { |
| return resolve(dependencyInfo) |
| } else { |
| return reject(new Error('Not enabled via cli')) |
| } |
| } |
|
|
| process.stdout.write('Save this preference to your project\'s package.json file? (y/N): ') |
|
|
| setTimeout(() => { |
| console.log('') |
| return afterUserInput(enabled, false) |
| }, 15000) |
|
|
| stdin.removeAllListeners('data') |
| stdin.on('data', async function (data) { |
| try { |
| const savePreference = data.trim().toLowerCase() === 'y' |
| if (savePreference) { |
| await savePreferencesToRootPackage(dependencyInfo.rootPackage.packageJsonPath, enabled) |
| } |
| return afterUserInput(enabled, savePreference) |
| } catch (err) { |
| logIfVerbose(err, console.error) |
| return reject(err) |
| } |
| }) |
| }) |
| } |
| } else { |
| resolve(dependencyInfo) |
| } |
| } |
| }) |
|
|
| redactSensitivePackageInfo(dependencyInfo) |
|
|
| const infoPayload = { |
| libraryType: 'npm', |
| rawPlatform: os.platform(), |
| rawArch: os.arch(), |
| nodeVersion: process.versions.node, |
| dependencyInfo: dependencyInfo |
| } |
|
|
| const data = JSON.stringify(infoPayload) |
| logIfVerbose(`Scarf payload: ${data}`) |
|
|
| const reqOptions = { |
| host: scarfHost, |
| port: localDevPort, |
| method: 'POST', |
| path: '/package-event/install', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Content-Length': data.length |
| }, |
| timeout: execTimeout |
| } |
|
|
| if (scarfApiToken) { |
| const authToken = Buffer.from(`n/a:${scarfApiToken}`).toString('base64') |
| reqOptions.headers.Authorization = `Basic ${authToken}` |
| } |
|
|
| await new Promise((resolve, reject) => { |
| const req = https.request(reqOptions, (res) => { |
| logIfVerbose(`Response status: ${res.statusCode}`) |
| resolve() |
| }) |
|
|
| req.on('error', error => { |
| logIfVerbose(error, console.error) |
| reject(error) |
| }) |
|
|
| req.on('timeout', error => { |
| logIfVerbose(error, console.error) |
| reject(error) |
| }) |
|
|
| req.write(data) |
| req.end() |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function findScarfInSubDepTree (pathToDep, deps) { |
| const depNames = Object.keys(deps || {}) |
|
|
| if (!depNames) { |
| return [] |
| } |
|
|
| const scarfFound = depNames.find(depName => depName === scarfLibName) |
| const output = [] |
| if (scarfFound) { |
| output.push(pathToDep.concat([{ name: scarfLibName, version: deps[scarfLibName].version, path: deps[scarfLibName].path }])) |
| } |
| for (let i = 0; i < depNames.length; i++) { |
| const depName = depNames[i] |
| const newPathToDep = pathToDep.concat([ |
| { |
| name: depName, |
| version: deps[depName].version, |
| scarfSettings: deps[depName].scarfSettings, |
| path: deps[depName].path |
| } |
| ]) |
| const results = findScarfInSubDepTree(newPathToDep, deps[depName].dependencies) |
| if (results) { |
| for (let j = 0; j < results.length; j++) { |
| output.push(results[j]) |
| } |
| } |
| } |
|
|
| return output |
| } |
|
|
| function findScarfInFullDependencyTree (tree) { |
| if (tree.name === scarfLibName) { |
| return [[{ name: scarfLibName, version: tree.version }]] |
| } else { |
| return findScarfInSubDepTree([packageDetailsFromDepInfo(tree)], tree.dependencies) |
| } |
| } |
|
|
| function packageDetailsFromDepInfo (tree) { |
| return { |
| name: tree.name, |
| version: tree.version, |
| scarfSettings: tree.scarfSettings, |
| path: tree.path |
| } |
| } |
|
|
| function rootPackageDepInfo (packageInfo) { |
| if (process.env.npm_config_global) { |
| packageInfo = Object.values(packageInfo.dependencies)[0] |
| } |
| const info = packageDetailsFromDepInfo(packageInfo) |
| info.packageJsonPath = `${packageInfo.path}/package.json` |
| return info |
| } |
|
|
| async function savePreferencesToRootPackage (path, optIn) { |
| const packageJsonString = await fsAsync.readFile(path) |
| const parsed = JSON.parse(packageJsonString) |
| parsed.scarfSettings = { |
| enabled: optIn |
| } |
| await fsAsync.writeFile(path, JSON.stringify(parsed, null, 2)) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function rateLimitedUserLog (rateLimitKey, toLog) { |
| const history = getRateLimitedLogHistory() |
| if (!hasHitRateLimit(rateLimitKey, history)) { |
| writeCurrentTimeToLogHistory(rateLimitKey, history) |
| console.log(toLog) |
| } else { |
| logIfVerbose(`[SUPPRESSED USER MESSAGE, RATE LIMIT HIT] ${toLog}`) |
| } |
| } |
|
|
| function getRateLimitedLogHistory () { |
| let history |
| try { |
| history = JSON.parse(fs.readFileSync(module.exports.tmpFileName())) |
| } catch (e) { |
| logIfVerbose(e) |
| } |
| return history || {} |
| } |
|
|
| |
| function hasHitRateLimit (rateLimitKey, history) { |
| if (!history || !history[rateLimitKey]) { |
| return false |
| } |
|
|
| const lastLog = history[rateLimitKey] |
| return (new Date().getTime() - lastLog) < userMessageThrottleTime |
| } |
|
|
| function writeCurrentTimeToLogHistory (rateLimitKey, history) { |
| history[rateLimitKey] = new Date().getTime() |
| fs.writeFileSync(module.exports.tmpFileName(), JSON.stringify(history)) |
| } |
|
|
| module.exports = { |
| redactSensitivePackageInfo, |
| hasHitRateLimit, |
| getRateLimitedLogHistory, |
| rateLimitedUserLog, |
| tmpFileName, |
| dirName, |
| processDependencyTreeOutput, |
| processGitRevParseOutput, |
| npmExecPath, |
| getDependencyInfo, |
| getGitShaFromRootPath, |
| reportPostInstall, |
| hashWithDefault, |
| findScarfInFullDependencyTree |
| } |
|
|
| if (require.main === module) { |
| try { |
| reportPostInstall().catch(e => { |
| |
| |
| logIfVerbose(`\n\n${e}`, console.error) |
| }).finally(() => { |
| process.exit(0) |
| }) |
| } catch (e) { |
| logIfVerbose(`\n\nTop level error: ${e}`, console.error) |
| process.exit(0) |
| } |
| } |
|
|