Spaces:
Sleeping
Sleeping
| 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 | |
| // Pulled into a function for test mocking | |
| function tmpFileName () { | |
| // throttle per user | |
| const username = os.userInfo().username | |
| return path.join(os.tmpdir(), `scarf-js-history-${username}.log`) | |
| } | |
| // Pulled into a function for test mocking | |
| function dirName () { | |
| return __dirname | |
| } | |
| function npmExecPath () { | |
| return process.env.npm_execpath | |
| } | |
| const userMessageThrottleTime = 1000 * 60 // 1 minute | |
| const execTimeout = 3000 | |
| // In general, these keys should never change to remain backwards compatible | |
| // with previous versions of Scarf. If we need to update them, we'll need to | |
| // make sure we can read the previous values as well | |
| 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) | |
| } | |
| } | |
| // SCARF_NO_ANALYTICS was the original variable, we'll get rid of it eventually | |
| 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' | |
| } | |
| // Packages that depend on Scarf can configure whether to should fire when | |
| // `npm install` is being run directly from within the package, rather than from a | |
| // dependent package | |
| 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 | |
| } | |
| } | |
| // We don't send any paths, hash package names and versions | |
| 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 | |
| } | |
| /* | |
| Scarf-js is automatically disabled when being run inside of a yarn install. | |
| The `npm_execpath` environment variable tells us which package manager is | |
| running our install | |
| */ | |
| 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 || {}) | |
| }) | |
| // Here, we find the dependency chain that corresponds to the scarf package we're currently in | |
| 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 any intermediate dependency in the chain of deps that leads to scarf | |
| // has disabled Scarf, we must respect that setting unless the user overrides it. | |
| 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')) | |
| } | |
| } | |
| } | |
| // packageJSONOverride: a test convenience to set a packageJSON explicitly. | |
| // Leave empty to use the actual root package.json. | |
| 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)) { | |
| // We'll only print the 'please opt in' text if the user hasn't | |
| // already opted out, and our logging rate limit hasn't been reached | |
| 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() | |
| }) | |
| } | |
| // Find all paths to Scarf from the json output of npm ls @scarf/scarf --json in | |
| // the root package being installed by the user | |
| // | |
| // [{ | |
| // scarf: {name: `@scarf/scarf`, version: '0.0.1'}, | |
| // parent: { name: 'scarfed-library', version: '1.0.0', scarfSettings: { defaultOptIn: true } }, | |
| // grandparent: { name: 'scarfed-lib-consumer', version: '1.0.0' } | |
| // }] | |
| 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)) | |
| } | |
| /* | |
| If Scarf-js appears in many different spots in a package's dependency tree, we | |
| want to avoid spamming the user with the same message informing them of | |
| Scarf's analytics. Rate limited logs will record timestamps of logging in a | |
| temp file. Before logging something to the user, we will verify we're not over | |
| the rate limit. | |
| */ | |
| 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 || {} | |
| } | |
| // Current rate limit: 1/minute | |
| 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 => { | |
| // This is an optional, best effort attempt. If there are any errors in | |
| // Scarf, we must not interfere with whatever the user is doing | |
| logIfVerbose(`\n\n${e}`, console.error) | |
| }).finally(() => { | |
| process.exit(0) | |
| }) | |
| } catch (e) { | |
| logIfVerbose(`\n\nTop level error: ${e}`, console.error) | |
| process.exit(0) | |
| } | |
| } | |