import { execSync, spawn } from 'child_process'; import fs, { existsSync } from 'fs'; import inquirer from 'inquirer'; import process from 'process'; import progressbar from 'string-progressbar'; import { table } from 'table'; import open from 'open'; import 'dotenv/config'; import { checkStrictTypesToChangedThemes } from './check-strict-types.mjs'; const remoteSSH = process.env.REMOTE_SSH; const publicThemesFolder = process.env.PUBLIC_THEMES_FOLDER; const publicThemesRepo = process.env.PUBLIC_THEMES_REPO; const isWin = process.platform === 'win32'; const isOnSandbox = process.cwd() === publicThemesFolder; /* Execute the first phase of a deployment. * Gets the last deployed hash from wpcom-themes * Version bump all themes that have changes * Clean the sandbox and ensure it is up-to-date * Push all changed files (including removal of deleted files) since the last deployment * Commit the version bump change to github * Create a tag in the github repository at this point of change */ export async function pushButtonDeploy( mode = 'deploy' ) { console.clear(); const isEnvFile = await checkEnvFile(); if ( ! isEnvFile ) { return; } const skipStrictTypes = process.argv.includes( '--skip-strict-types' ); if ( skipStrictTypes ) { console.log( 'Warning: Strict types check will be skipped' ); } const testMode = mode === 'test'; const branchName = testMode ? await executeCommand( 'git symbolic-ref --short HEAD' ) : 'trunk'; let prompt = await inquirer.prompt( [ { type: 'confirm', message: testMode ? `You are about to test a version bump on the ${ branchName } branch. Nothing will be deployed. Are you ready to continue?` : `You are about to version bump and deploy the ${ branchName } branch. Are you ready to continue?`, name: 'continue', default: false, }, ] ); if ( ! prompt.continue ) { return; } const canDeploy = await checkForDeployability( testMode ? 'test' : 'deploy' ); if ( ! canDeploy ) { return; } try { const changedThemes = await getChangedThemes( testMode ? 'test' : 'deploy' ); if ( ! changedThemes.length ) { console.log( `\n\nEverything is up to date, so there is no need to version bump or deploy.\n\n` ); return; } if ( changedThemes.length > 0 ) { if ( ! testMode ) { if ( ! skipStrictTypes ) { await checkStrictTypesToChangedThemes(); } } const changesWereMade = await versionBumpThemes( changedThemes ); if ( changesWereMade ) { prompt = await inquirer.prompt( [ { type: 'confirm', message: 'Are you happy with the version bump and changelog updates? Make any manual adjustments now if necessary.', name: 'continue', default: false, }, ] ); if ( ! prompt.continue ) { console.log( `Aborted Automated Deploy Process at version bump changes.` ); return; } // Commit the version bump changes await commitVersionBump( mode ); prompt = await inquirer.prompt( [ { type: 'confirm', message: testMode ? 'Are you ready to deploy these changes? [Test mode: changes will not actually be deployed]' : 'Are you ready to deploy these changes?', name: 'continue', default: false, }, ] ); if ( ! prompt.continue ) { console.log( `Aborted Automated Deploy during deploy phase.` ); return; } try { if ( testMode ) { console.log( `Test mode: Skipping deploy pub step.` ); } else { if ( ! isOnSandbox ) { // Push changes to sandbox so they can be deployed await pushChangesToSandbox( changedThemes ); } await deployThemes( changedThemes ); } } catch ( err ) { prompt = await inquirer.prompt( [ { type: 'confirm', message: `There was an error deploying themes. ${ err } Do you wish to continue to the next step?`, name: 'continue', default: false, }, ] ); if ( ! prompt.continue ) { console.log( `Aborted Automated Deploy during deploy phase.` ); return; } } console.log( `The following themes have changed:\n${ changedThemes.join( ', ' ) }` ); } } console.log( '\n\nAll Done!!\n\n' ); } catch ( err ) { console.log( 'Error with deploy script: ', err ); } } /* Deploy a collection of themes. Part of the push-button-deploy process. Can also be triggered to deploy a single theme with the command: node ./.theme-utils/index.mjs deploy-theme THEMENAME */ export async function deployThemes( themes ) { let response; const failedThemes = []; const progress = startProgress( themes.length ); for ( let theme of themes ) { console.log( `Deploying ${ theme }` ); let deploySuccess = false; let attempt = 0; while ( ! deploySuccess && attempt <= 2 ) { attempt++; console.log( `\nattempt #${ attempt }\n\n` ); try { response = isOnSandbox ? await executeCommand( `cd ${ publicThemesFolder }; ${ process.env.DEPLOY_COMMAND } pub ${ theme }`, true ) : await executeOnSandbox( `cd ${ publicThemesFolder }; deploy pub ${ theme }; exit;`, true, true ); deploySuccess = response.includes( 'successfully deployed to' ); } catch ( error ) { deploySuccess = false; } if ( ! deploySuccess ) { console.log( 'Deploy was not successful. Trying again in 10 seconds...' ); await new Promise( ( resolve ) => setTimeout( resolve, 10000 ) ); } else { console.log( 'Deploy successful.' ); } } if ( ! deploySuccess ) { console.log( `${ theme } was not sucessfully deployed and should be deployed manually.` ); failedThemes.push( theme ); } progress.increment(); } if ( failedThemes.length ) { const tableConfig = { columnDefault: { width: 40, }, header: { alignment: 'center', content: `Following themes are not deployed.`, }, }; console.log( table( failedThemes.map( ( t ) => [ t ] ), tableConfig ) ); } } /* Create list of changes from git logs Optionally pass in boolean bulletPoints to add bullet points to each commit log */ async function getCommitLogs( bulletPoints, theme, prTitle ) { let hash = await getLastDeployedHash(); let format = 'format:%s'; let themeDir = ''; if ( bulletPoints ) { format = 'format:"* %s"'; } if ( theme ) { themeDir = `-- ./${ theme }`; } let logs = await executeCommand( `git log --reverse --pretty=${ format } ${ hash }..HEAD ${ themeDir } --grep="\\[Automated\\]" --invert-grep` ); if ( prTitle === 'Theme version bump' ) { // Default to the last commit message if the PR title is the default value logs = await executeCommand( `git log -1 --pretty=format:"* %s" ${ themeDir }` ); } else { logs = `* ${ prTitle }`; } // Remove any double quotes and backticks from commit messages logs = logs.replace( /["`]/g, '' ); return logs; } /* Determine what changes would be deployed */ export async function deployPreview() { console.clear(); console.log( 'To ensure accuracy clean your sandbox before previewing. (It is not automatically done).' ); await checkForDeployability(); let changedThemes = await getChangedThemes(); console.log( `The following themes have changes:\n${ changedThemes }` ); let logs = await getCommitLogs(); console.log( `\n\nCommit log of changes to be deployed:\n\n${ logs }\n\n` ); } /* Check to ensure that: * The current branch is /trunk * That trunk is up-to-date with origin/trunk */ async function checkForDeployability( mode = 'deploy' ) { // Test mode if ( mode !== 'deploy' ) { return true; } try { const branchName = await executeCommand( 'git symbolic-ref --short HEAD' ); // Only trunk branch can be deployed if ( branchName !== 'trunk' ) { console.log( '\n\nOnly the /trunk branch can be deployed.\n\n' ); return false; } // Clean sandbox for trunk branch await cleanSandbox(); return true; } catch ( error ) { console.error( '\n\nError checking branch deployability: ', error.message, '\n\n' ); return false; } } /* Get list of changed themes. */ export async function getChangedThemes( mode = 'deploy' ) { const testOrBump = mode !== 'deploy'; // Get the last deployed hash const hash = await getLastDeployedHash(); const hashUrl = `${ publicThemesRepo }/commit/${ hash }`; if ( ! testOrBump ) { console.log( `Checking for changed themes since \x1b[4m${ hashUrl }\x1b[0m` ); } else { console.log( `Checking for changed themes on current branch` ); } try { // Get list of changed files const changedFiles = testOrBump ? await executeCommand( `git diff --name-only $(git merge-base HEAD trunk)..HEAD` ) : await executeCommand( `git diff --name-only ${ hash } trunk` ); console.log( 'Found following changed themes:' ); console.log( changedFiles ); // If no changes, return empty array if ( ! changedFiles ) { return []; } // Ignore any files/folders in .sandbox-ignore const ignoreList = fs .readFileSync( '.theme-utils/.sandbox-ignore', 'utf8' ) .split( '\n' ) .filter( ( line ) => line && ! line.startsWith( '#' ) ) // Remove empty lines and comments .map( ( line ) => line.trim() ); // Convert the changed files string into an array and make sure it's unique const directories = changedFiles .split( '\n' ) // Remove empty strings .filter( ( dir ) => dir ) .filter( ( dir ) => { // Exclude directories that match any pattern in the ignore list return ! ignoreList.some( ( pattern ) => { if ( pattern.endsWith( '/*' ) ) { return dir.indexOf( pattern.slice( 0, -2 ) ) > -1; } return dir.indexOf( pattern ) > -1; } ); } ) // Simplify to only the theme directory .map( ( file ) => file.split( '/' )[ 0 ] ) // Remove duplicates .filter( ( dir, index, self ) => self.indexOf( dir ) === index ); // Filter out non-theme directories by checking for style.css const themeDirectories = []; for ( const dir of directories ) { if ( fs.existsSync( `${ dir }/style.css` ) ) { themeDirectories.push( dir ); } } return themeDirectories; } catch ( error ) { console.error( 'Error checking for changed themes:', error ); return []; } } /* Provide the hash of the last managed deployment. This hash is used to determine all the changes that have happened between that point and the current point. */ export async function getLastDeployedHash() { const tag = execSync( `git describe --tags --abbrev=0 trunk` ) .toString() .trim(); return execSync( `git rev-list -n 1 tags/${ tag }` ).toString().trim(); } /* Version bump (increment version patch) any theme project that has had changes since the last deployment. If a theme's version has already been changed since that last deployment then do not version bump it. If any theme projects have had a version bump also version bump the parent project. If a theme has changes also update its changelog. */ export async function versionBumpThemes( themeSlugs, bumpRoot = true, hash = null, prTitle = null ) { if ( ! hash ) { hash = await getLastDeployedHash(); } let themes = themeSlugs ? themeSlugs : await getChangedThemes(); let changesWereMade = false; let hasNewTheme = false; let versionBumpedThemes = []; if ( themes.length === 0 ) { return changesWereMade; } for ( let theme of themes ) { // Skip if the theme is a default theme const defaultThemes = fs .readFileSync( '.theme-utils/.default-themes', 'utf8' ) .split( '\n' ) .filter( ( line ) => line && ! line.startsWith( '#' ) ) .map( ( line ) => line.trim() ); if ( defaultThemes.includes( theme ) ) { console.log( `Skipping ${ theme } as it is a default theme.` ); continue; } const hasVersionBump = await checkThemeForVersionBump( theme, hash ); if ( hasVersionBump ) { console.log( theme + ' already has version bump.' ); if ( hasVersionBump === 'new' ) { hasNewTheme = true; } continue; } await versionBumpTheme( theme, true ); await updateThemeChangelog( theme, true, prTitle ); versionBumpedThemes.push( theme ); changesWereMade = true; } if ( bumpRoot ) { // Version bump the root project if there were changes to any of the themes const rootHasVersionBump = await checkProjectForVersionBump( hash ); if ( versionBumpedThemes.length === 0 ) { if ( hasNewTheme ) { console.log( '\nA new theme was detected, so a version bump is not required' ); changesWereMade = true; } } else if ( ! rootHasVersionBump ) { await executeCommand( 'npm version patch --no-git-tag-version' ); await executeCommand( 'git add package.json' ); changesWereMade = true; } } if ( changesWereMade ) { // Add package-lock.json if any changes were made await executeCommand( 'git add package-lock.json' ); if ( versionBumpedThemes.length > 0 ) { console.log( `Version-bumped themes: ${ versionBumpedThemes.join( ', ' ) }` ); } } else { console.log( '\nNo theme changes were found, so a version bump is not required' ); } return changesWereMade; } /* Version bump (increment version patch) any theme project that has had changes since the last "Version Bump" commit (e.g. for running via the GH Action, as we do not want to use the last wpcom deployment as a reference.) If a theme's version has already been changed since that last commit then do not version bump it. If a theme has changes also update its changelog. Does not update the root project version. */ export async function versionBumpThemesFromLastBump( themeSlugs ) { const hash = await getLastDeployedHash(); let themes = themeSlugs ? themeSlugs : await getChangedThemes(); let changesWereMade = false; if ( themes.length === 0 ) { return changesWereMade; } console.log( `Version bumping changed themes: ${ themes.join( ', ' ) }` ); for ( let theme of themes ) { const hasVersionBump = await checkThemeForVersionBump( theme, hash ); if ( hasVersionBump ) { continue; } await versionBumpTheme( theme, true ); await updateThemeChangelog( theme, true ); changesWereMade = true; } console.log( `Version bumping complete:`, changesWereMade ? 'themes successfully version bumped' : 'no changes were made' ); return changesWereMade; } /* Get theme metadata from the style.css file. */ export function getThemeMetadata( styleCss, attribute, trimWPCom = true ) { if ( ! styleCss || ! attribute ) { return null; } switch ( attribute ) { case 'Version': const version = styleCss .match( /(?<=Version:\s*).*?(?=\s*\r?\n)/gs )?.[ 0 ] ?.trim(); if ( ! version ) { return null; } return trimWPCom ? version.replace( '-wpcom', '' ) : version; case 'Requires at least': return styleCss .match( /(?<=Requires at least:\s*).*?(?=\s*\r?\n|\rg)/gs )?.[ 0 ] ?.trim(); default: return styleCss .match( new RegExp( `(?<=${ attribute }:\\s*).*?(?=\\s*\\r?\\n|\\rg)`, 'gs' ) )?.[ 0 ] ?.trim(); } } /* Rebuild theme changelog from a given starting hash */ export async function rebuildThemeChangelog( theme, since ) { console.log( `Rebuilding ${ theme } changelog since ${ since || 'forever' }` ); if ( since ) { since = `${ since }..HEAD`; } else { since = 'HEAD'; } let hashes = await executeCommand( `git rev-list ${ since } -- ./${ theme }` ); hashes = hashes.split( '\n' ); let logs = '== Changelog ==\n'; for ( let hash of hashes ) { let log = await executeCommand( `git log -n 1 --pretty=format:"* %s" ${ hash }` ); if ( log.includes( 'Version Bump' ) ) { let previousStyleString = await executeCommand( `git show ${ hash }:${ theme }/style.css 2>/dev/null` ); let version = getThemeMetadata( previousStyleString, 'Version' ); logs += `\n= ${ version } =\n`; } else { // Remove any double quotes from commit messages log = log.replace( /"/g, '' ); logs += log + '\n'; } } // Get theme readme.txt let readmeFilePath = `${ theme }/readme.txt`; // Update readme.txt fs.readFile( readmeFilePath, 'utf8', function ( err, data ) { let changelogSection = '== Changelog =='; let regex = new RegExp( '^.*' + changelogSection + '.*$', 'gm' ); let formattedChangelog = data.replace( regex, logs ); fs.writeFile( readmeFilePath, formattedChangelog, 'utf8', function ( err ) { if ( err ) return console.log( err ); } ); } ); } /* Update theme changelog using current commit logs. Used by versionBumpThemes to update each theme changelog. */ export async function updateThemeChangelog( theme, addChanges, prTitle ) { // Get theme version let styleCss = fs.readFileSync( `${ theme }/style.css`, 'utf8' ); let version = getThemeMetadata( styleCss, 'Version' ); let logs = await getCommitLogs( true, theme, prTitle ); // Get theme readme.txt let readmeFilePath = `${ theme }/readme.txt`; if ( ! existsSync( readmeFilePath ) ) { console.log( '\x1b[31m%s\x1b[0m', `Error: ${ theme } is missing a readme.txt file, which means the theme changelog cannot be updated. Please add this file manually and run the version bump again (npm run deploy:version-bump) to update the changelog.` ); } if ( existsSync( readmeFilePath ) ) { // Build changelog entry let newChangelogEntry = `== Changelog == = ${ version } = ${ logs }`; // Update readme.txt fs.readFile( readmeFilePath, 'utf8', function ( err, data ) { let changelogSection = '== Changelog =='; let regex = new RegExp( '^.*' + changelogSection + '.*$', 'gm' ); let formattedChangelog = data.replace( regex, newChangelogEntry ); fs.writeFile( readmeFilePath, formattedChangelog, 'utf8', function ( err ) { if ( err ) return console.log( err ); } ); } ); // Stage readme.txt if ( addChanges ) { await executeCommand( `git add ${ readmeFilePath }` ); } } } /* Version Bump a Theme. Used by versionBumpThemes to do the work of version bumping. First increment the patch version in style.css Then update any of these files with the new version: [package.json, style.scss, style-child-theme.scss] */ async function versionBumpTheme( theme, addChanges ) { await executeCommand( `perl -pi -e 's/Version: ((\\d+\\.)*)(\\d+)(.*)$/"Version: ".$1.($3+1).$4/ge' ${ theme }/style.css`, true ); await executeCommand( `git add ${ theme }/style.css` ); let styleCss = fs.readFileSync( `${ theme }/style.css`, 'utf8' ); let currentVersion = getThemeMetadata( styleCss, 'Version', false ); if ( ! currentVersion ) { console.log( '\x1b[31m%s\x1b[0m', `Error: Cannot version bump ${ theme } as there is no "Version:" attribute in the style.css file.` ); } if ( currentVersion ) { let filesToUpdate = await executeCommand( `find ${ theme } -not \\( -path "*/node_modules/*" -prune \\) -and \\( -name package.json -or -name style.scss -or -name style-rtl.css -or -name style-child-theme.scss \\) -maxdepth 3` ); filesToUpdate = filesToUpdate .split( '\n' ) .filter( ( item ) => item != '' ); for ( let file of filesToUpdate ) { const isPackageJson = file === `${ theme }/package.json`; if ( isPackageJson ) { // Update theme/package.json and package-lock.json await executeCommand( `npm version ${ currentVersion.replace( '-wpcom', '' ) } --workspace=${ theme }` ); if ( addChanges ) { await executeCommand( `git add ${ file }` ); // Always add package-lock.json if it exists after npm version if ( fs.existsSync( `${ theme }/package-lock.json` ) ) { await executeCommand( `git add ${ theme }/package-lock.json` ); } } } else { await executeCommand( `perl -pi -e 's/Version: (.*)$/"Version: '${ currentVersion }'"/ge' ${ file }` ); if ( addChanges ) { await executeCommand( `git add ${ file }` ); } } } } } /* Determine if a theme has had a version bump since a given hash. Used by versionBumpThemes Compares the value of 'version' in style.css between the hash, trunk, and current value */ async function checkThemeForVersionBump( theme, hash ) { try { const trunkLatest = await executeCommand( `git rev-parse origin/trunk` ); const trunkStyleString = await executeCommand( `git show ${ trunkLatest }:${ theme }/style.css 2>/dev/null || echo ""` ); const previousStyleString = await executeCommand( `git show ${ hash }:${ theme }/style.css 2>/dev/null || echo ""` ); if ( ! trunkStyleString || ! previousStyleString ) { console.log( `Theme ${ theme } is new so it does not need a version bump` ); return 'new'; } let previousVersion = getThemeMetadata( previousStyleString, 'Version' ); let currentVersion; try { const styleCss = fs.readFileSync( `${ theme }/style.css`, 'utf8' ); currentVersion = getThemeMetadata( styleCss, 'Version' ); } catch ( error ) { console.log( `Error reading style.css for ${ theme }: ${ error.message }` ); return false; } if ( ! previousVersion || ! currentVersion ) { console.log( `Warning: Could not determine version for ${ theme } - version bump check skipped` ); return false; } return previousVersion !== currentVersion; } catch ( error ) { console.log( `Error checking version bump for ${ theme }: ${ error.message }` ); return false; } } /* Determine if the project has had a version bump since a given hash. Used by versionBumpThemes Compares the value of 'version' in package.json between the hash and current value */ async function checkProjectForVersionBump( hash ) { let previousPackageString = await executeCommand( ` git show ${ hash }:./package.json 2>/dev/null ` ); let previousPackage = JSON.parse( previousPackageString ); let currentPackage = JSON.parse( fs.readFileSync( `./package.json` ) ); return previousPackage.version != currentPackage.version; } /* Provide a list of 'actionable' themes - themes that have a style.css file - themes that are not listed in the .ignore file */ async function getActionableThemes() { let result = await executeCommand( `for d in */; do if test -f "./$d/style.css"; then echo $d; fi done` ); // Ignore themes listed in .ignore file const ignoreList = fs.readFileSync( '.ignore', 'utf8' ).split( '\n' ); result = result .split( '\n' ) .filter( ( item ) => item?.trim() ) .filter( ( item ) => ! ignoreList.includes( item ) ) .map( ( item ) => item.replace( /\//g, '' ) ); return result; } /* Clean the theme sandbox. checkout origin/trunk and ensure it's up-to-date. Remove any other changes. */ export async function cleanSandbox() { const isOnSandbox = process.cwd() === publicThemesFolder; console.log( 'Cleaning the Themes Sandbox' ); const commands = ` cd ${ publicThemesFolder }; git reset --hard HEAD -q; git clean -fd -q; git checkout trunk -q; git pull -q `; if ( isOnSandbox ) { await executeCommand( commands ); } else { await executeOnSandbox( commands, true ); } } /* Push exactly what is here (all files) up to the sandbox (with the exclusion of files noted in .sandbox-ignore) */ export async function pushToSandbox() { console.log( 'Pushing All Themes to Sandbox.' ); let allThemes = await getActionableThemes(); console.log( `Syncing ${ allThemes.length } themes` ); for ( let theme of allThemes ) { await pushThemeToSandbox( theme ); } } export async function pushThemeToSandbox( theme ) { console.log( `Syncing ${ theme }` ); return executeCommand( ` rsync -avR --no-p --no-times --delete -m --exclude-from='.theme-utils/.sandbox-ignore' ./${ theme }/ wpcom-sandbox:${ publicThemesFolder }/ `, true ); } /* Push only (and every) change since the point-of-diversion from /trunk Remove files from the sandbox that have been removed since the last deployed hash */ export async function pushChangesToSandbox( changedThemes = null ) { console.log( 'Pushing Changed Themes to Sandbox.' ); if ( ! changedThemes ) { changedThemes = await getChangedThemes(); } if ( changedThemes.length === 0 ) { '\x1b[31m%s\x1b[0m', console.log( 'Error: No changed themes to push' ); return; } console.log( `Syncing ${ changedThemes.length } themes` ); for ( let theme of changedThemes ) { await pushThemeToSandbox( theme ); } } export async function pullAllThemes() { console.log( 'Pulling ALL themes from sandbox.' ); let allThemes = await getActionableThemes(); for ( let theme of allThemes ) { try { await executeCommand( ` rsync -avr --no-p --no-times --delete -m --exclude-from='.theme-utils/.sandbox-ignore' wpcom-sandbox:${ publicThemesFolder }/${ theme }/ ./${ theme }/ `, true ); } catch ( err ) { console.log( 'Error pulling:', err ); } } } /* Create a GitHub pull request with the given message based on the contents currently in the sandbox. Open the GitHub pull request in your browser. Provide the URL of the GitHub pull request. */ export async function createGithubPR( commitMessage ) { console.log( 'Creating GitHub Pull Request' ); let result = await executeOnSandbox( ` cd ${ publicThemesFolder }; git branch -D deploy git checkout -b deploy git add --all git commit -m "${ commitMessage }" git push origin deploy gh pr create --fill --head deploy `, true ); let githubUrl = getGithubUrlFromResponse( result ); console.log( 'PR Created at: ', githubUrl ); if ( githubUrl ) { open( githubUrl ); } return githubUrl; } /* Utility to pull the GitHub URL from the PR creation command. Used by createGithubPR */ function getGithubUrlFromResponse( response ) { return response ?.split( '\n' ) ?.filter( ( item ) => item.includes( 'http' ) ) // filter out lines that include 'http' ?.pop(); // get the last URL } /* Commit version bump changes and create a new tag. In the description include the commit logs since the given hash. */ async function commitVersionBump( mode = 'deploy' ) { console.log( 'Tagging deployment' ); const testMode = mode === 'test'; const currentBranch = await executeCommand( 'git symbolic-ref --short HEAD' ); const branchName = testMode ? currentBranch : `trunk`; let projectVersion = await executeCommand( `node -p "require('./package.json').version"` ); let logs = await getCommitLogs(); const timestamp = Date.now().toString( 36 ); let tag = testMode ? `test-${ timestamp }` : `v${ projectVersion }`; let message = `Version Bump and Deploy Themes ${ tag } \n\n${ logs }`; await executeCommand( ` git commit -m "${ message }" git push --set-upstream origin ${ branchName } git tag -a ${ tag } -m "${ message }" git push origin ${ tag } `, true ); } /* Execute a command on the sandbox. Expects the following to be configured in your ~/.ssh/config file: Host wpcom-sandbox User wpdev HostName SANDBOXURL.wordpress.com ForwardAgent yes */ export function executeOnSandbox( command, logResponse, enablePsudoterminal ) { if ( enablePsudoterminal ) { return executeCommand( `ssh -tt -A ${ remoteSSH } << EOF ${ command } EOF`, logResponse ); } return executeCommand( `ssh -TA ${ remoteSSH } << EOF ${ command } EOF`, logResponse ); } /* Execute a command locally. */ export async function executeCommand( command, logResponse ) { const timeout = 2 * 60 * 1000; // 2 min return new Promise( ( resolove, reject ) => { let child; let response = ''; let errResponse = ''; if ( isWin ) { child = spawn( 'cmd.exe', [ '/s', '/c', '"' + command + '"' ], { windowsVerbatimArguments: true, stdio: [ process.stdin, 'pipe', 'pipe' ], detached: true, } ); } else { /* * Determines the shell to execute the command. * - Prioritizes using the user's default shell unless it's fish, a known problematic shell. * - In this case, falls back to `/bin/bash` for better syntax compatibility. */ let shellPath = process.env.SHELL || '/bin/bash'; if ( shellPath.includes( 'fish' ) && fs.existsSync( '/bin/bash' ) ) { shellPath = '/bin/bash'; } child = spawn( shellPath, [ '-c', command ], { stdio: [ process.stdin, 'pipe', 'pipe' ], detached: true, } ); } var timer = setTimeout( () => { try { process.kill( -child.pid, 'SIGKILL' ); } catch ( e ) { console.log( 'Cannot kill process' ); } }, timeout ); child.stdout.on( 'data', ( data ) => { response += data; if ( logResponse ) { console.log( data.toString() ); } } ); child.stderr.on( 'data', ( data ) => { errResponse += data; if ( logResponse ) { console.log( data.toString() ); } } ); child.on( 'exit', ( code ) => { clearTimeout( timer ); if ( code !== 0 ) { reject( errResponse.trim() ); } resolove( response.trim() ); } ); } ); } /* Check if the .env file is present and if all the variables are set. */ async function checkEnvFile() { if ( ! fs.existsSync( '.env' ) ) { console.log( '\x1b[31m%s\x1b[0m', 'Error: No .env file found. Please create one in the root of the repository using the content from https://mc.a8c.com/pb/371ac/.' ); return false; } const envVars = [ 'REMOTE_SSH', 'PUBLIC_THEMES_FOLDER', 'BUILD_THEME_ZIP', 'GLOTPRESS_SCRIPT', 'THEME_DOWNLOADS_URL', 'PUBLIC_THEMES_REPO', 'DEPLOY_COMMAND', ]; for ( const envVar of envVars ) { if ( ! process.env[ envVar ] ) { console.log( `\x1b[31m%s\x1b[0m`, `Error: ${ envVar } is not set in .env file. See https://mc.a8c.com/pb/371ac/ for the correct content.` ); return false; } } return true; } /* Handle a progress bar. */ export function startProgress( length ) { if ( ! process.stdout.isTTY ) { return { increment() {} }; } let current = 0; function render() { const [ progress, percentage ] = progressbar.filledBar( length, current ); console.log( '\nProgress:', [ progress, Math.round( percentage * 100 ) / 100 ], `${ current }/${ length }\n` ); } render(); return { increment() { current++; process.stdout.moveCursor?.( 0, -3 ); render(); }, }; }