/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format * @oncall react_native */ const {PACKAGES_DIR} = require('../consts'); const {parseArgs} = require('@pkgjs/parseargs'); // $FlowFixMe[untyped-import]: TODO type ansi-styles const ansiStyles = require('ansi-styles'); const chalk = require('chalk'); const {execSync, spawnSync} = require('child_process'); const {promises: fs} = require('fs'); const nullthrows = require('nullthrows'); const {hostname, tmpdir, userInfo} = require('os'); const path = require('path'); // $FlowFixMe[untyped-import]: TODO type rimraf const rimraf = require('rimraf'); // $FlowFixMe[untyped-import]: TODO type signedsource const SignedSource = require('signedsource'); // $FlowFixMe[untyped-import]: TODO type supports-color const supportsColor = require('supports-color'); const DEVTOOLS_FRONTEND_REPO_URL = 'https://github.com/facebookexperimental/rn-chrome-devtools-frontend'; const config = { allowPositionals: true, options: { branch: {type: 'string'}, 'keep-scratch': {type: 'boolean'}, nohooks: {type: 'boolean'}, help: {type: 'boolean'}, 'create-diff': {type: 'boolean'}, }, }; /*:: type DiffBaseInfo = { packagePath: string, baseGitRevision: string, }; type ParsedBuildInfo = { gitRevision: string, isLocalCheckout: boolean, }; */ async function main() { const { positionals, values: { help, branch, nohooks, 'keep-scratch': keepScratch, 'create-diff': createDiff, }, } = parseArgs(config); if (help === true) { showHelp(); process.exitCode = 0; return; } const localCheckoutPath = positionals?.[0]; if (branch == null && !localCheckoutPath?.length) { console.error(chalk.red('Error: Missing option --branch')); showHelp(); process.exitCode = 1; return; } console.log('\n' + chalk.bold.inverse('Syncing debugger-frontend') + '\n'); const scratchPath = await fs.mkdtemp( path.join(tmpdir(), 'debugger-frontend-build-'), ); process.stdout.write(chalk.dim(`Scratch path: ${scratchPath}\n\n`)); await checkRequiredTools(); const packagePath = path.join(PACKAGES_DIR, 'debugger-frontend'); let diffBaseInfo; if (createDiff) { diffBaseInfo = await checkCanCreateDiff(packagePath); } await buildDebuggerFrontend(packagePath, scratchPath, localCheckoutPath, { branch: branch ?? '', gclientSyncOptions: {nohooks: nohooks === true}, }); if (createDiff && diffBaseInfo) { await createSyncDiff(diffBaseInfo, scratchPath); } await cleanup(scratchPath, keepScratch === true); process.stdout.write( chalk.green('Sync done.') + ' Check in any updated files under packages/debugger-frontend.\n', ); } function showHelp() { console.log(` Usage: node scripts/debugger-frontend/sync-and-build [OPTIONS] [checkout path] Sync and build the debugger frontend into @react-native/debugger-frontend. By default, checks out the currently pinned revision of the DevTools frontend. If an existing checkout path is provided, builds it instead. Options: --branch The DevTools frontend branch to use. Ignored when providing a local checkout path. --nohooks Don't run gclient hooks in the devtools checkout (useful for existing checkouts). --keep-scratch Don't clean up temporary files. `); } async function checkRequiredTools() { process.stdout.write('Checking that required tools are available' + '\n'); await spawnSafe('git', ['--version'], {stdio: 'ignore'}); try { await spawnSafe('gclient', ['--version'], {stdio: 'ignore'}); await spawnSafe('which', ['gn'], {stdio: 'ignore'}); await spawnSafe('which', ['autoninja'], {stdio: 'ignore'}); } catch (e) { process.stderr.write( 'Install depot_tools first: ' + 'https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/depot_tools_tutorial.html#_setting_up' + '\n', ); throw e; } process.stdout.write('\n'); } async function buildDebuggerFrontend( packagePath /*: string */, scratchPath /*: string */, localCheckoutPath /*: ?string */, {branch, gclientSyncOptions} /*: $ReadOnly<{ branch: string, gclientSyncOptions: $ReadOnly<{nohooks: boolean}>, }>*/, ) { let checkoutPath; if (localCheckoutPath == null) { const scratchCheckoutPath = path.join(scratchPath, 'devtools-frontend'); await fs.mkdir(scratchPath, {recursive: true}); await checkoutDevToolsFrontend(scratchCheckoutPath, branch); checkoutPath = scratchCheckoutPath; } else { checkoutPath = localCheckoutPath; } await setupGclientWorkspace(scratchPath, checkoutPath, gclientSyncOptions); const {buildPath, gnArgsSummary} = await performReleaseBuild(checkoutPath); const destPathInPackage = path.join(packagePath, 'dist', 'third-party'); await cleanPackageFiles(destPathInPackage); await copyFrontendFilesToPackage(buildPath, destPathInPackage); await copyLicenseToPackage(checkoutPath, destPathInPackage); await generateBuildInfo({ checkoutPath, packagePath, branch, isLocalCheckout: localCheckoutPath != null, gclientSyncOptions, gnArgsSummary, }); } async function checkoutDevToolsFrontend( checkoutPath /*: string */, branch /*: string */, ) { process.stdout.write('Checking out devtools-frontend\n'); await fs.mkdir(checkoutPath, {recursive: true}); await spawnSafe('git', [ 'clone', DEVTOOLS_FRONTEND_REPO_URL, '--branch', branch, '--single-branch', '--depth', '1', checkoutPath, ]); process.stdout.write('\n'); } async function setupGclientWorkspace( scratchPath /*: string */, checkoutPath /*: string */, {nohooks} /*: $ReadOnly<{nohooks: boolean}> */, ) { process.stdout.write('Setting up gclient workspace' + '\n'); await spawnSafe( 'gclient', ['config', '--unmanaged', checkoutPath, '--name', 'devtools-frontend'], { cwd: scratchPath, }, ); await spawnSafe( 'gclient', ['sync', '--no-history', ...(nohooks ? ['--nohooks'] : [])], { env: { ...process.env, DEPOT_TOOLS_UPDATE: '0', }, cwd: scratchPath, }, ); process.stdout.write('\n'); } async function performReleaseBuild( checkoutPath /*: string */, ) /*: Promise<{buildPath: string, gnArgsSummary: string}> */ { process.stdout.write('Performing release build of devtools-frontend' + '\n'); const buildPath = path.join(checkoutPath, 'out/Release'); await fs.mkdir(buildPath, {recursive: true}); await fs.writeFile( path.join(buildPath, 'args.gn'), // NOTE: Per the DevTools repo's documentation, is_official_build has nothing // to do with branding and only controls certain release build optimisations. 'is_official_build=true\n', ); await spawnSafe('gn', ['gen', 'out/Release'], { cwd: checkoutPath, }); const {stdout: gnArgsStdout} = await spawnSafe( 'gn', ['args', 'out/Release', '--list', '--short', '--overrides-only'], { cwd: checkoutPath, stdio: ['ignore', 'pipe', 'inherit'], }, ); const gnArgsSummary = gnArgsStdout.toString().trim(); process.stdout.write(chalk.dim(gnArgsSummary) + '\n'); await spawnSafe('autoninja', ['-C', 'out/Release'], {cwd: checkoutPath}); process.stdout.write('\n'); return {gnArgsSummary, buildPath}; } async function cleanPackageFiles(destPathInPackage /*: string */) { process.stdout.write( 'Cleaning stale generated files in debugger-frontend' + '\n', ); rimraf.sync(destPathInPackage); process.stdout.write('\n'); } async function copyFrontendFilesToPackage( buildPath /*: string */, destPathInPackage /*: string */, ) { process.stdout.write( 'Copying built devtools-frontend files to debugger-frontend' + '\n\n', ); // The DevTools build generates a manifest of all files meant for packaging // into Chrome. These are exactly the files we need to ship. const files = JSON.parse( await fs.readFile( path.join(buildPath, 'gen', 'input_grd_files.json'), 'utf8', ), ); await Promise.all( files.map(async file => { const destPath = path.join(destPathInPackage, file); const destDir = path.dirname(destPath); await fs.mkdir(destDir, {recursive: true}); await fs.copyFile(path.join(buildPath, 'gen', file), destPath); }), ); } async function copyLicenseToPackage( checkoutPath /*: string */, destPathInPackage /*: string */, ) { process.stdout.write( 'Copying LICENSE from devtools-frontend to debugger-frontend package\n\n', ); await fs.copyFile( path.join(checkoutPath, 'LICENSE'), path.join(destPathInPackage, 'LICENSE'), ); } async function generateBuildInfo( info /*: $ReadOnly<{ checkoutPath: string, isLocalCheckout: boolean, branch: string, packagePath: string, gclientSyncOptions: $ReadOnly<{nohooks: boolean}>, gnArgsSummary: string, }> */, ) { process.stdout.write('Generating BUILD_INFO for debugger-frontend\n\n'); const gitStatusLines = execSync('git status --porcelain', { cwd: info.checkoutPath, encoding: 'utf-8', }) .trim() .split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .map(line => ' ' + line); if (!gitStatusLines.length) { gitStatusLines.push(' '); } const gnSummaryLines = info.gnArgsSummary .split('\n') .map(line => ' ' + line.trim()); if (!gnSummaryLines.length) { gnSummaryLines.push(' '); } const contents = [ SignedSource.getSigningToken(), 'Git revision: ' + execSync('git rev-parse HEAD', { cwd: info.checkoutPath, encoding: 'utf-8', }).trim(), 'Built with --nohooks: ' + String(info.gclientSyncOptions.nohooks), 'Is local checkout: ' + String(info.isLocalCheckout), ...(!info.isLocalCheckout ? [ 'Remote URL: ' + DEVTOOLS_FRONTEND_REPO_URL, 'Remote branch: ' + info.branch, ] : ['Hostname: ' + hostname(), 'User: ' + userInfo().username]), 'GN build args (overrides only): ', ...gnSummaryLines, 'Git status in checkout:', ...gitStatusLines, '', ].join('\n'); await fs.writeFile( path.join(info.packagePath, 'BUILD_INFO'), SignedSource.signFile(contents), ); } async function cleanup(scratchPath /*: string */, keepScratch /*: boolean */) { if (!keepScratch) { process.stdout.write('Cleaning up temporary files\n\n'); await rimraf.sync(scratchPath); } else { process.stdout.write( 'Not cleaning up temporary files because of --keep-scratch\n\n', ); } } async function spawnSafe( cmd /*: string */, args /*: Array */ = [], opts /*: child_process$spawnSyncOpts */ = {}, ) /*: Promise<{ stdout: string | Buffer, stderr: string | Buffer, }> */ { process.stdout.write(` > ${cmd} ${args.join(' ')}\n`); if (supportsColor.stdout) { process.stdout.write(ansiStyles.dim.open); } if (supportsColor.stderr) { process.stderr.write(ansiStyles.dim.open); } try { const {error, status, signal, stdout, stderr} = spawnSync(cmd, args, { stdio: ['ignore', 'inherit', 'inherit'], ...opts, }); if (error) { throw error; } if (status != null && status !== 0) { throw new Error(`Command failed with exit code ${status}`); } if (signal != null) { throw new Error(`Command terminated by signal ${signal}`); } return {stdout, stderr}; } finally { if (supportsColor.stdout) { process.stdout.write(ansiStyles.dim.close); } if (supportsColor.stderr) { process.stderr.write(ansiStyles.dim.close); } } } async function checkCanCreateDiff( packagePath /*: string */, ) /*: Promise */ { process.stdout.write('Checking that we can create a diff' + '\n'); try { const {stdout: hgRootStdout} = await spawnSafe('hg', ['root'], { cwd: packagePath, stdio: ['ignore', 'pipe', 'inherit'], }); const repoRoot = hgRootStdout.toString().trim(); const projectid = ( await fs.readFile(path.join(repoRoot, '.projectid'), 'utf8') ).trim(); if (projectid !== 'fbsource') { throw new Error( 'Expected .projectid to contain "fbsource" but found: ' + projectid, ); } await spawnSafe('jf', ['-v'], {cwd: packagePath, stdio: 'ignore'}); } catch (e) { process.stderr.write( 'Must be in an fbsource checkout (Meta-only) to create a diff\n', ); throw e; } try { const {stdout: hgStatusStdout} = await spawnSafe( 'hg', ['status', 'BUILD_INFO'], {cwd: packagePath, stdio: ['ignore', 'pipe', 'inherit']}, ); if (hgStatusStdout.toString().trim() !== '') { throw new Error( 'Must have a clean base BUILD_INFO file to create a diff', ); } const {gitRevision: baseGitRevision} = await readBuildInfo(packagePath); return { packagePath, baseGitRevision, }; } catch (e) { process.stderr.write('Must have a BUILD_INFO file to create a diff\n'); throw e; } } async function readBuildInfo( packagePath /*: string*/, ) /*: Promise */ { const buildInfo = await fs.readFile( path.join(packagePath, 'BUILD_INFO'), 'utf8', ); const GIT_REV_RE = /^Git revision: ([0-9a-f]{40})/m; const gitRevision = nullthrows( GIT_REV_RE.exec(buildInfo), 'Could not extract git revision from BUILD_INFO', )[1]; const isLocalCheckout = !/^Is local checkout: false$/m.test(buildInfo); return { isLocalCheckout, gitRevision, }; } async function createSyncDiff( diffBaseInfo /*: DiffBaseInfo */, scratchPath /*: string */, ) { process.stdout.write('Creating a sync diff\n'); const {packagePath, baseGitRevision} = diffBaseInfo; const baseGitRevisionShort = baseGitRevision.slice(0, 7); const {gitRevision: newGitRevision, isLocalCheckout} = await readBuildInfo(packagePath); const newGitRevisionShort = newGitRevision.slice(0, 7); const commitMessage = [ (isLocalCheckout ? 'DO NOT LAND ' : '') + `[RN] Update debugger-frontend from ${baseGitRevisionShort}...${newGitRevisionShort}`, '', 'Summary:', `Changelog: [Internal] - Update \`@react-native/debugger-frontend\` from ${baseGitRevisionShort}...${newGitRevisionShort}`, '', `Resyncs \`@react-native/debugger-frontend\` from GitHub - see \`rn-chrome-devtools-frontend\` [changelog](${DEVTOOLS_FRONTEND_REPO_URL}/compare/${baseGitRevision}...${newGitRevision}).`, '', 'Test Plan: CI', '', 'Reviewers: #rn-debugging', '', 'Tags: msdkland[metro]', '', ].join('\n'); // TODO: Generate a detailed inline changelog from Git commits? const commitMessageFile = path.join(scratchPath, 'commit-msg'); await fs.writeFile(commitMessageFile, commitMessage); await spawnSafe( 'hg', ['commit', packagePath, '--addremove', '-l', commitMessageFile], {cwd: packagePath}, ); await spawnSafe('jf', ['submit', '--draft'], { cwd: packagePath, }); } if (require.main === module) { // eslint-disable-next-line no-void void main(); }