UNPKG

zapier-platform-cli

Version:

The CLI for managing integrations in Zapier Developer Platform.

811 lines (716 loc) 24.8 kB
const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { constants: { Z_BEST_COMPRESSION }, } = require('node:zlib'); const _ = require('lodash'); const archiver = require('archiver'); const colors = require('colors/safe'); const esbuild = require('esbuild'); const fse = require('fs-extra'); const { createUpdateNotifier } = require('./esm-wrapper'); const decompress = require('./decompress'); const { BUILD_DIR, BUILD_PATH, PLATFORM_PACKAGE, SOURCE_PATH, UPDATE_NOTIFICATION_INTERVAL, } = require('../constants'); const { copyDir, walkDir, walkDirLimitedLevels } = require('./files'); const { iterfilter, itermap } = require('./itertools'); const { endSpinner, flattenCheckResult, prettyJSONstringify, startSpinner, } = require('./display'); const { getLinkedAppConfig, getWritableApp, upload: _uploadFunc, validateApp, } = require('./api'); const { copyZapierWrapper, deleteZapierWrapper } = require('./zapierwrapper'); const checkMissingAppInfo = require('./check-missing-app-info'); const { findCorePackageDir, isWindows, runCommand } = require('./misc'); const { isBlocklisted, respectGitIgnore } = require('./ignore'); const { localAppCommand } = require('./local'); const { throwForInvalidVersion } = require('./version'); const { getPackageManager } = require('./package-manager'); const debug = require('debug')('zapier:build'); // const stripPath = (cwd, filePath) => filePath.split(cwd).pop(); // Given entry points in a directory, return an array of file paths that are // required for the build. The returned paths are relative to workingDir. const findRequiredFiles = async (workingDir, entryPoints) => { const appPackageJson = require(path.join(workingDir, 'package.json')); const isESM = appPackageJson.type === 'module'; const format = isESM ? 'esm' : 'cjs'; const result = await esbuild.build({ entryPoints, outdir: './build', bundle: true, platform: 'node', metafile: true, logLevel: 'warning', logOverride: { 'require-resolve-not-external': 'silent', }, external: ['../test/userapp'], format, // Setting conditions to an empty array to exclude 'module' condition, // which Node.js doesn't use. https://esbuild.github.io/api/#conditions conditions: [], write: false, // no need to write outfile absWorkingDir: workingDir, tsconfigRaw: '{}', loader: { '.node': 'file', }, }); let relPaths = Object.keys(result.metafile.inputs); if (path.sep === '\\') { // The paths in result.metafile.inputs use forward slashes even on Windows, // path.normalize() will convert them to backslashes. relPaths = relPaths.map((x) => path.normalize(x)); } return relPaths; }; // From a file path relative to workingDir, traverse up the directory tree until // it finds a directory that looks like a package directory, which either // contains a package.json file or whose path matches a pattern like // 'node_modules/(@scope/)package-name'. // Returns null if no package directory is found. const getPackageDir = (workingDir, relPath) => { const nm = `node_modules${path.sep}`; let i = relPath.lastIndexOf(nm); if (i < 0) { let dir = path.dirname(relPath); for (let j = 0; j < 100; j++) { const packageJsonPath = path.resolve(workingDir, dir, 'package.json'); if (fs.existsSync(packageJsonPath)) { return dir; } const nextDir = path.dirname(dir); if (nextDir === dir) { break; } dir = nextDir; } return null; } i += nm.length; if (relPath[i] === '@') { // For scoped package, e.g. node_modules/@zapier/package-name const j = relPath.indexOf(path.sep, i + 1); if (j < 0) { return null; } i = j + 1; // skip the next path.sep } const j = relPath.indexOf(path.sep, i); if (j < 0) { return null; } return relPath.substring(0, j); }; function expandRequiredFiles(workingDir, relPaths) { const expandedPaths = new Set(relPaths); for (const relPath of relPaths) { const packageDir = getPackageDir(workingDir, relPath); if (packageDir) { expandedPaths.add(path.join(packageDir, 'package.json')); } } return expandedPaths; } // Yields files and symlinks (as fs.Direnv objects) from a directory // recursively, excluding names that are typically not needed in the build, // such as .git, .env, build, etc. function* walkDirWithPresetBlocklist(dir) { const shouldInclude = (entry) => { const relPath = path.relative(dir, path.join(entry.parentPath, entry.name)); return !isBlocklisted(relPath); }; yield* iterfilter(shouldInclude, walkDir(dir)); } // Yields files and symlinks (as fs.Direnv objects) from a directory recursively // that match any of the given or preset regex patterns. function* walkDirWithPatterns(dir, patterns) { const sep = path.sep.replaceAll('\\', '\\\\'); // escape backslash for regex const presetPatterns = [ `${sep}definition\\.json$`, `${sep}package\\.json$`, `${sep}aws-sdk${sep}apis${sep}.*\\.json$`, ]; patterns = [...presetPatterns, ...(patterns || [])].map( (x) => new RegExp(x, 'i'), ); const shouldInclude = (entry) => { const relPath = path.join(entry.parentPath, entry.name); if (isBlocklisted(relPath)) { return false; } for (const pattern of patterns) { if (pattern.test(relPath)) { return true; } } return false; }; yield* iterfilter(shouldInclude, walkDir(dir)); } // Opens a zip file for writing. Returns an Archiver object. const openZip = (outputPath) => { const output = fs.createWriteStream(outputPath); const zip = archiver('zip', { zlib: { level: Z_BEST_COMPRESSION }, // Sets the compression level. }); const streamCompletePromise = new Promise((resolve, reject) => { output.on('close', resolve); zip.on('error', reject); }); zip.finish = async () => { // zip.finalize() doesn't return a promise, so here we create a // zip.finish() function so the caller can await it. // So callers: Use `await zip.finish()` and avoid zip.finalize(). zip.finalize(); await streamCompletePromise; }; if (path.sep === '\\') { // On Windows, patch zip.file() and zip.symlink() so they normalize the path // separator to '/' because we're supposed to use '/' in a zip file // regardless of the OS platform. Those are the only two methods we're // currently using. If you wanted to call other zip.xxx methods, you should // patch them here as well. const origFileMethod = zip.file; zip.file = (filepath, data) => { filepath = path.normalize(filepath); return origFileMethod.call(zip, filepath, data); }; const origSymlinkMethod = zip.symlink; zip.symlink = (name, target, mode) => { name = path.normalize(name); target = path.normalize(target); return origSymlinkMethod.call(zip, name, target, mode); }; } zip.pipe(output); return zip; }; const getNearestNodeModulesDir = (workingDir, relPath) => { if (path.basename(relPath) === 'package.json') { const nmDir = path.resolve( workingDir, path.dirname(relPath), 'node_modules', ); return fs.existsSync(nmDir) ? path.relative(workingDir, nmDir) : null; } else if (relPath.includes('node_modules')) { let dir = path.dirname(relPath); for (let i = 0; i < 100; i++) { if (dir.endsWith(`${path.sep}node_modules`)) { return dir; } const nextDir = path.dirname(dir); if (nextDir === dir) { break; } dir = nextDir; } } let dir = path.dirname(relPath); for (let i = 0; i < 100; i++) { const nmDir = path.join(dir, 'node_modules'); if (fs.existsSync(path.resolve(workingDir, nmDir))) { return nmDir; } const nextDir = path.dirname(dir); if (nextDir === dir) { break; } dir = nextDir; } return null; }; const countLeadingDoubleDots = (relPath) => { const parts = relPath.split(path.sep); for (let i = 0; i < parts.length; i++) { if (parts[i] !== '..') { return i; } } return 0; }; // Join all relPaths with workingDir and return the common ancestor directory. const findCommonAncestor = (workingDir, relPaths) => { let maxLeadingDoubleDots = 0; if (isWindows()) { for (const relPath of relPaths) { if (relPath.match(/^[a-zA-Z]:/)) { // On Windows, relPath can be absolute if it starts with a different // drive letter than workingDir. return 'C:\\'; } else { maxLeadingDoubleDots = Math.max( maxLeadingDoubleDots, countLeadingDoubleDots(relPath), ); } } } else { for (const relPath of relPaths) { maxLeadingDoubleDots = Math.max( maxLeadingDoubleDots, countLeadingDoubleDots(relPath), ); } } let commonAncestor = workingDir; for (let i = 0; i < maxLeadingDoubleDots; i++) { commonAncestor = path.dirname(commonAncestor); } return commonAncestor; }; const stripDriveLetterForZip = (pathStr) => { return pathStr.replace(/^[cC]:\\/, '').replace(/^([a-zA-Z]):/, '$1'); }; const writeBuildZipDumbly = async (workingDir, zip) => { for (const entry of walkDirWithPresetBlocklist(workingDir)) { const absPath = path.resolve(entry.parentPath, entry.name); const relPath = path.relative(workingDir, absPath); if (entry.isFile()) { zip.file(absPath, { name: relPath }); } else if (entry.isSymbolicLink()) { const target = path.relative(entry.parentPath, fs.realpathSync(absPath)); zip.symlink(relPath, target, 0o644); } } }; const writeBuildZipSmartly = async (workingDir, zip) => { const entryPoints = [path.resolve(workingDir, 'zapierwrapper.js')]; const indexPath = path.resolve(workingDir, 'index.js'); if (fs.existsSync(indexPath)) { // Necessary for CommonJS integrations. The zapierwrapper they use require() // the index.js file using a variable. esbuild can't detect it, so we need // to add it here specifically. entryPoints.push(indexPath); } const appConfig = await getLinkedAppConfig(workingDir, false); const relPaths = Array.from( new Set([ // Files found by esbuild and their package.json files ...expandRequiredFiles( workingDir, await findRequiredFiles(workingDir, entryPoints), ), // Files matching includeInBuild and other preset patterns ...itermap( (entry) => path.relative(workingDir, path.join(entry.parentPath, entry.name)), walkDirWithPatterns(workingDir, appConfig?.includeInBuild), ), ]), ).sort(); const zipRoot = findCommonAncestor(workingDir, relPaths) || workingDir; if (zipRoot !== workingDir) { const appDirRelPath = path.relative(zipRoot, workingDir); // zapierwrapper.js and index.js are entry points. // 'config' is the default directory that the 'config' npm package expects // to find config files at the root directory. const linkNames = ['zapierwrapper.js', 'index.js', 'config']; for (const name of linkNames) { if (fs.existsSync(path.join(workingDir, name))) { zip.symlink(name, path.join(appDirRelPath, name), 0o644); } } const filenames = ['package.json', 'definition.json']; for (const name of filenames) { const absPath = path.resolve(workingDir, name); zip.file(absPath, { name, mode: 0o644 }); } } // Write required files to the zip for (const relPath of relPaths) { const absPath = path.resolve(workingDir, relPath); const nameInZip = stripDriveLetterForZip(path.relative(zipRoot, absPath)); if (nameInZip === 'package.json' && zipRoot !== workingDir) { // Ignore workspace root's package.json continue; } zip.file(absPath, { name: nameInZip, mode: 0o644 }); } // Next, find all symlinks that are either: (1) immediate children of any // node_modules directory, or (2) located one directory level below a // node_modules directory. (1) is for the case of node_modules/package_name. // (2) is for the case of node_modules/@scope/package_name. const nodeModulesDirs = new Set(); for (const relPath of relPaths) { const nmDir = getNearestNodeModulesDir(workingDir, relPath); if (nmDir) { nodeModulesDirs.add(nmDir); } } for (const relNmDir of nodeModulesDirs) { const absNmDir = path.resolve(workingDir, relNmDir); const symlinks = iterfilter( (entry) => { // Only include symlinks that are not in node_modules/.bin directories return ( entry.isSymbolicLink() && !entry.parentPath.endsWith(`${path.sep}node_modules${path.sep}.bin`) ); }, walkDirLimitedLevels(absNmDir, 2), ); for (const symlink of symlinks) { const absPath = path.resolve( workingDir, symlink.parentPath, symlink.name, ); const nameInZip = stripDriveLetterForZip(path.relative(zipRoot, absPath)); const targetInZip = path.relative( stripDriveLetterForZip(symlink.parentPath), stripDriveLetterForZip(fs.realpathSync(absPath)), ); zip.symlink(nameInZip, targetInZip, 0o644); } } }; // Creates the build.zip file. const makeBuildZip = async ( workingDir, zipPath, disableDependencyDetection, ) => { const zip = openZip(zipPath); if (disableDependencyDetection) { // Ideally, if dependency detection works really well, we don't need to // support --disable-dependency-detection at all. We might want to phase out // this code path over time. Also, this doesn't handle workspaces. await writeBuildZipDumbly(workingDir, zip); } else { await writeBuildZipSmartly(workingDir, zip); } await zip.finish(); }; const makeSourceZip = async (workingDir, zipPath) => { const relPaths = Array.from( itermap( (entry) => path.relative(workingDir, path.join(entry.parentPath, entry.name)), walkDirWithPresetBlocklist(workingDir), ), ); const finalRelPaths = respectGitIgnore(workingDir, relPaths).sort(); const zip = openZip(zipPath); debug('\nSource files:'); for (const relPath of finalRelPaths) { if (relPath === 'definition.json' || relPath === 'zapierwrapper.js') { // These two files are generated at build time; // they're not part of the source code. continue; } const absPath = path.resolve(workingDir, relPath); debug(` ${absPath}`); zip.file(absPath, { name: relPath, mode: 0o644 }); } debug(); await zip.finish(); }; const maybeNotifyAboutOutdated = async () => { // Made async because createUpdateNotifier() uses dynamic import() to load ESM-only update-notifier package // find a package.json for the app and notify on the core dep // `build` won't run if package.json isn't there, so if we get to here we're good const requiredVersion = _.get( require(path.resolve('./package.json')), `dependencies.${PLATFORM_PACKAGE}`, ); if (requiredVersion) { const notifier = await createUpdateNotifier({ pkg: { name: PLATFORM_PACKAGE, version: requiredVersion }, updateCheckInterval: UPDATE_NOTIFICATION_INTERVAL, }); if (notifier.update && notifier.update.latest !== requiredVersion) { notifier.notify({ message: `There's a newer version of ${colors.cyan( PLATFORM_PACKAGE, )} available.\nConsider updating the dependency in your\n${colors.cyan( 'package.json', )} (${colors.grey(notifier.update.current)}${colors.green( notifier.update.latest, )}) and then running ${colors.red('zapier test')}.`, }); } } }; const maybeRunBuildScript = async (options = {}) => { const ZAPIER_BUILD_KEY = '_zapier-build'; // Make sure we don't accidentally call the Zapier build hook inside itself if (process.env.npm_lifecycle_event !== ZAPIER_BUILD_KEY) { const pJson = require( path.resolve(options.cwd || process.cwd(), 'package.json'), ); if (_.get(pJson, ['scripts', ZAPIER_BUILD_KEY])) { if (options.printProgress) { startSpinner(`Running ${ZAPIER_BUILD_KEY} script`); } await runCommand('npm', ['run', ZAPIER_BUILD_KEY], options); if (options.printProgress) { endSpinner(); } } } }; const extractMissingModulePath = (testDir, error) => { // Extract relative path to print a more user-friendly error message if (error.message && error.message.includes('MODULE_NOT_FOUND')) { const searchString = `Cannot find module '${testDir}/`; const idx = error.message.indexOf(searchString); if (idx >= 0) { const pathStart = idx + searchString.length; const pathEnd = error.message.indexOf("'", pathStart); if (pathEnd >= 0) { const relPath = error.message.substring(pathStart, pathEnd); return relPath; } } } return null; }; const testBuildZip = async (zipPath) => { const osTmpDir = await fse.realpath(os.tmpdir()); const testDir = path.join( osTmpDir, 'zapier-' + crypto.randomBytes(4).toString('hex'), ); try { await fse.ensureDir(testDir); await decompress(zipPath, testDir); const wrapperPath = path.join(testDir, 'zapierwrapper.js'); if (!fs.existsSync(wrapperPath)) { throw new Error('zapierwrapper.js not found in build.zip.'); } const indexPath = path.join(testDir, 'index.js'); const indexExists = fs.existsSync(indexPath); try { await runCommand(process.execPath, ['zapierwrapper.js'], { cwd: testDir, timeout: 5000, }); if (indexExists) { await runCommand(process.execPath, ['index.js'], { cwd: testDir, timeout: 5000, }); } } catch (error) { // Extract relative path to print a more user-friendly error message const relPath = extractMissingModulePath(testDir, error); if (relPath) { throw new Error( `Detected a missing file in build.zip: '${relPath}'\n` + `You may have to add it to ${colors.bold.underline('includeInBuild')} ` + `in your ${colors.bold.underline('.zapierapprc')} file.`, ); } else if (error.message) { // Hide the unzipped temporary directory error.message = error.message .replaceAll(`file://${testDir}/`, '') .replaceAll(`${testDir}/`, ''); } throw error; } } finally { // Clean up test directory await fse.remove(testDir); } }; const _buildFunc = async ( { skipDepInstall = false, disableDependencyDetection = false, skipValidation = false, printProgress = true, checkOutdated = true, } = {}, snapshotVersion, ) => { const maybeStartSpinner = printProgress ? startSpinner : () => {}; const maybeEndSpinner = printProgress ? endSpinner : () => {}; if (checkOutdated) { await maybeNotifyAboutOutdated(); // await needed because function is now async due to ESM import } const appDir = process.cwd(); let workingDir; if (skipDepInstall) { workingDir = appDir; debug('Building in app directory: ', workingDir); } else { const osTmpDir = await fse.realpath(os.tmpdir()); workingDir = path.join( osTmpDir, 'zapier-' + crypto.randomBytes(4).toString('hex'), ); debug('Building in temp directory: ', workingDir); } await maybeRunBuildScript({ printProgress }); // make sure our directories are there await fse.ensureDir(workingDir); const buildDir = path.join(appDir, BUILD_DIR); await fse.ensureDir(buildDir); if (!skipDepInstall) { maybeStartSpinner('Copying project to temp directory'); const copyFilter = (src) => !src.endsWith('.zip'); await copyDir(appDir, workingDir, { filter: copyFilter }); maybeEndSpinner(); const packageManager = await getPackageManager(); maybeStartSpinner( `Installing project dependencies using ${packageManager.executable}`, ); const output = await runCommand( packageManager.executable, ['install', '--production'], { cwd: workingDir, }, ); // `npm install` may fail silently without returning a non-zero exit code, // need to check further here const corePath = path.join(workingDir, 'node_modules', PLATFORM_PACKAGE); if (!fs.existsSync(corePath)) { throw new Error( 'Could not install dependencies properly. Error log:\n' + output.stderr, ); } } maybeEndSpinner(); maybeStartSpinner('Applying entry point files'); const corePath = findCorePackageDir(workingDir); await copyZapierWrapper(corePath, workingDir); maybeEndSpinner(); maybeStartSpinner('Building app definition.json'); const rawDefinition = await localAppCommand( { command: 'definition' }, workingDir, false, ); const version = snapshotVersion ?? rawDefinition.version; throwForInvalidVersion(version); try { fs.writeFileSync( path.join(workingDir, 'definition.json'), prettyJSONstringify(rawDefinition), ); } catch (err) { debug('\nFile Write Error:\n', err, '\n'); throw new Error( `Unable to write ${workingDir}/definition.json, please check file permissions!`, ); } maybeEndSpinner(); if (!skipValidation) { /** * 'Validation' as performed here is twofold: * (Locally - `validate`) A Schema Validation is performed locally against the versions schema * (Remote - `validateApp`) Both the Schema, AppVersion, and Auths are validated */ maybeStartSpinner('Validating project schema and style'); const validationErrors = await localAppCommand( { command: 'validate' }, workingDir, false, ); if (validationErrors.length) { debug('\nErrors:\n', validationErrors, '\n'); throw new Error( 'We hit some validation errors, try running `zapier validate` to see them!', ); } // No need to mention specifically we're validating style checks as that's // implied from `zapier validate`, though it happens as a separate process const styleChecksResponse = await validateApp(rawDefinition); if (_.get(styleChecksResponse, ['errors', 'total_failures'])) { debug( '\nErrors:\n', prettyJSONstringify(styleChecksResponse.errors.results), '\n', ); throw new Error( 'We hit some style validation errors, try running `zapier validate` to see them!', ); } maybeEndSpinner(); if (_.get(styleChecksResponse, ['warnings', 'total_failures'])) { console.log(colors.yellow('WARNINGS:')); const checkIssues = flattenCheckResult(styleChecksResponse); for (const issue of checkIssues) { if (issue.category !== 'Errors') { console.log(colors.yellow(`- ${issue.description}`)); } } console.log(colors.yellow('Run `zapier validate` for more details.')); } } else { debug('\nWarning: Skipping Validation'); } maybeStartSpinner('Zipping project and dependencies'); const zipPath = path.join(appDir, BUILD_PATH); await makeBuildZip(workingDir, zipPath, disableDependencyDetection); await makeSourceZip( workingDir, path.join(appDir, SOURCE_PATH), disableDependencyDetection, ); maybeEndSpinner(); if (skipDepInstall) { maybeStartSpinner('Cleaning up temp files'); await deleteZapierWrapper(workingDir); fs.rmSync(path.join(workingDir, 'definition.json')); maybeEndSpinner(); } else { maybeStartSpinner('Cleaning up temp directory'); await fse.remove(workingDir); maybeEndSpinner(); } if (!isWindows()) { // "Testing build" doesn't work on Windows because of some permission issue // with symlinks maybeStartSpinner('Testing build'); await testBuildZip(zipPath); maybeEndSpinner(); } return zipPath; }; const buildAndOrUpload = async ( { build = false, upload = false } = {}, buildOpts, snapshotVersion, ) => { if (!(build || upload)) { throw new Error('must either build or upload'); } // we should able to build without any auth, but if we're uploading, we should fail early let app; if (upload) { app = await getWritableApp(); checkMissingAppInfo(app); } if (build) { await _buildFunc(buildOpts, snapshotVersion); } if (upload) { await _uploadFunc(app, buildOpts, snapshotVersion); } }; module.exports = { buildAndOrUpload, findRequiredFiles, makeBuildZip, makeSourceZip, maybeRunBuildScript, walkDirWithPresetBlocklist, };