refactor: replace unmaintained shelljs dependency by execa (#10358)

Co-authored-by: sebastien <lorber.sebastien@gmail.com>
This commit is contained in:
ozaki 2025-02-28 14:31:01 +01:00 committed by GitHub
parent a6ef3897e0
commit 7f4a37949e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 139 additions and 85 deletions

View file

@ -25,11 +25,11 @@
"@docusaurus/logger": "3.7.0", "@docusaurus/logger": "3.7.0",
"@docusaurus/utils": "3.7.0", "@docusaurus/utils": "3.7.0",
"commander": "^5.1.0", "commander": "^5.1.0",
"execa": "5.1.1",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"semver": "^7.5.4", "semver": "^7.5.4",
"shelljs": "^0.8.5",
"supports-color": "^9.4.0", "supports-color": "^9.4.0",
"tslib": "^2.6.0" "tslib": "^2.6.0"
}, },

View file

@ -10,7 +10,7 @@ import {fileURLToPath} from 'url';
import path from 'path'; import path from 'path';
import _ from 'lodash'; import _ from 'lodash';
import {logger} from '@docusaurus/logger'; import {logger} from '@docusaurus/logger';
import shell from 'shelljs'; import execa from 'execa';
import prompts, {type Choice} from 'prompts'; import prompts, {type Choice} from 'prompts';
import supportsColor from 'supports-color'; import supportsColor from 'supports-color';
import {escapeShellArg, askPreferredLanguage} from '@docusaurus/utils'; import {escapeShellArg, askPreferredLanguage} from '@docusaurus/utils';
@ -70,9 +70,9 @@ function findPackageManagerFromUserAgent(): PackageManager | undefined {
} }
async function askForPackageManagerChoice(): Promise<PackageManager> { async function askForPackageManagerChoice(): Promise<PackageManager> {
const hasYarn = shell.exec('yarn --version', {silent: true}).code === 0; const hasYarn = (await execa.command('yarn --version')).exitCode === 0;
const hasPnpm = shell.exec('pnpm --version', {silent: true}).code === 0; const hasPnpm = (await execa.command('pnpm --version')).exitCode === 0;
const hasBun = shell.exec('bun --version', {silent: true}).code === 0; const hasBun = (await execa.command('bun --version')).exitCode === 0;
if (!hasYarn && !hasPnpm && !hasBun) { if (!hasYarn && !hasPnpm && !hasBun) {
return 'npm'; return 'npm';
@ -533,7 +533,7 @@ export default async function init(
const gitCloneCommand = `${gitCommand} ${escapeShellArg( const gitCloneCommand = `${gitCommand} ${escapeShellArg(
source.url, source.url,
)} ${escapeShellArg(dest)}`; )} ${escapeShellArg(dest)}`;
if (shell.exec(gitCloneCommand).code !== 0) { if (execa.command(gitCloneCommand).exitCode !== 0) {
logger.error`Cloning Git template failed!`; logger.error`Cloning Git template failed!`;
process.exit(1); process.exit(1);
} }
@ -583,24 +583,28 @@ export default async function init(
const cdpath = path.relative('.', dest); const cdpath = path.relative('.', dest);
const pkgManager = await getPackageManager(dest, cliOptions); const pkgManager = await getPackageManager(dest, cliOptions);
if (!cliOptions.skipInstall) { if (!cliOptions.skipInstall) {
shell.cd(dest); process.chdir(dest);
logger.info`Installing dependencies with name=${pkgManager}...`; logger.info`Installing dependencies with name=${pkgManager}...`;
// ...
if ( if (
shell.exec( (
pkgManager === 'yarn' await execa.command(
? 'yarn' pkgManager === 'yarn'
: pkgManager === 'bun' ? 'yarn'
? 'bun install' : pkgManager === 'bun'
: `${pkgManager} install --color always`, ? 'bun install'
{ : `${pkgManager} install --color always`,
env: { {
...process.env, env: {
// Force coloring the output, since the command is invoked by ...process.env,
// shelljs, which is not an interactive shell // Force coloring the output, since the command is invoked by
...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}), // shelljs, which is not an interactive shell
...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}),
},
}, },
}, )
).code !== 0 ).exitCode !== 0
) { ) {
logger.error('Dependency installation failed.'); logger.error('Dependency installation failed.');
logger.info`The site directory has already been created, and you can retry by typing: logger.info`The site directory has already been created, and you can retry by typing:

View file

@ -58,8 +58,7 @@
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/picomatch": "^2.3.0", "@types/picomatch": "^2.3.0",
"commander": "^5.1.0", "commander": "^5.1.0",
"picomatch": "^2.3.1", "picomatch": "^2.3.1"
"shelljs": "^0.8.5"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0",

View file

@ -22,6 +22,7 @@
"@docusaurus/types": "3.7.0", "@docusaurus/types": "3.7.0",
"@docusaurus/utils-common": "3.7.0", "@docusaurus/utils-common": "3.7.0",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"execa": "5.1.1",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"github-slugger": "^1.5.0", "github-slugger": "^1.5.0",
@ -33,7 +34,6 @@
"micromatch": "^4.0.5", "micromatch": "^4.0.5",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"resolve-pathname": "^3.0.0", "resolve-pathname": "^3.0.0",
"shelljs": "^0.8.5",
"tslib": "^2.6.0", "tslib": "^2.6.0",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"utility-types": "^3.10.0", "utility-types": "^3.10.0",

View file

@ -9,12 +9,13 @@ import {jest} from '@jest/globals';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import {createTempRepo} from '@testing-utils/git'; import {createTempRepo} from '@testing-utils/git';
import shell from 'shelljs'; import execa from 'execa';
import { import {
getGitLastUpdate, getGitLastUpdate,
LAST_UPDATE_FALLBACK, LAST_UPDATE_FALLBACK,
readLastUpdateData, readLastUpdateData,
} from '@docusaurus/utils'; } from '../lastUpdateUtils';
describe('getGitLastUpdate', () => { describe('getGitLastUpdate', () => {
const {repoDir} = createTempRepo(); const {repoDir} = createTempRepo();
@ -69,7 +70,10 @@ describe('getGitLastUpdate', () => {
}); });
it('git does not exist', async () => { it('git does not exist', async () => {
const mock = jest.spyOn(shell, 'which').mockImplementationOnce(() => null); const mock = jest.spyOn(execa, 'sync').mockImplementationOnce(() => {
throw new Error('Git does not exist');
});
const consoleMock = jest const consoleMock = jest
.spyOn(console, 'warn') .spyOn(console, 'warn')
.mockImplementation(() => {}); .mockImplementation(() => {});

View file

@ -8,9 +8,15 @@
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import _ from 'lodash'; import _ from 'lodash';
import shell from 'shelljs'; // TODO replace with async-first version import execa from 'execa';
const realHasGitFn = () => !!shell.which('git'); const realHasGitFn = () => {
try {
return execa.sync('git', ['--version']).exitCode === 0;
} catch (error) {
return false;
}
};
// The hasGit call is synchronous IO so we memoize it // The hasGit call is synchronous IO so we memoize it
// The user won't install Git in the middle of a build anyway... // The user won't install Git in the middle of a build anyway...
@ -123,27 +129,13 @@ export async function getFileCommitDate(
file, file,
)}"`; )}"`;
const result = await new Promise<{ const result = await execa(command, {
code: number; cwd: path.dirname(file),
stdout: string; shell: true,
stderr: string;
}>((resolve) => {
shell.exec(
command,
{
// Setting cwd is important, see: https://github.com/facebook/docusaurus/pull/5048
cwd: path.dirname(file),
silent: true,
},
(code, stdout, stderr) => {
resolve({code, stdout, stderr});
},
);
}); });
if (result.exitCode !== 0) {
if (result.code !== 0) {
throw new Error( throw new Error(
`Failed to retrieve the git history for file "${file}" with exit code ${result.code}: ${result.stderr}`, `Failed to retrieve the git history for file "${file}" with exit code ${result.exitCode}: ${result.stderr}`,
); );
} }

View file

@ -10,7 +10,7 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import {createRequire} from 'module'; import {createRequire} from 'module';
import shell from 'shelljs'; import execa from 'execa';
import {logger} from '@docusaurus/logger'; import {logger} from '@docusaurus/logger';
import semver from 'semver'; import semver from 'semver';
import updateNotifier from 'update-notifier'; import updateNotifier from 'update-notifier';
@ -111,8 +111,8 @@ export default async function beforeCli() {
return undefined; return undefined;
} }
const yarnVersionResult = shell.exec('yarn --version', {silent: true}); const yarnVersionResult = await execa.command('yarn --version');
if (yarnVersionResult?.code === 0) { if (yarnVersionResult.exitCode === 0) {
const majorVersion = parseInt( const majorVersion = parseInt(
yarnVersionResult.stdout?.trim().split('.')[0] ?? '', yarnVersionResult.stdout?.trim().split('.')[0] ?? '',
10, 10,

View file

@ -52,6 +52,7 @@
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"eta": "^2.2.0", "eta": "^2.2.0",
"eval": "^0.1.8", "eval": "^0.1.8",
"execa": "5.1.1",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"html-tags": "^3.3.1", "html-tags": "^3.3.1",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
@ -68,7 +69,6 @@
"react-router-dom": "^5.3.4", "react-router-dom": "^5.3.4",
"semver": "^7.5.4", "semver": "^7.5.4",
"serve-handler": "^6.1.6", "serve-handler": "^6.1.6",
"shelljs": "^0.8.5",
"tinypool": "^1.0.2", "tinypool": "^1.0.2",
"tslib": "^2.6.0", "tslib": "^2.6.0",
"update-notifier": "^6.0.2", "update-notifier": "^6.0.2",

View file

@ -9,7 +9,7 @@ import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import shell from 'shelljs'; import execa from 'execa';
import {hasSSHProtocol, buildSshUrl, buildHttpsUrl} from '@docusaurus/utils'; import {hasSSHProtocol, buildSshUrl, buildHttpsUrl} from '@docusaurus/utils';
import {loadContext, type LoadContextParams} from '../server/site'; import {loadContext, type LoadContextParams} from '../server/site';
import {build} from './build/build'; import {build} from './build/build';
@ -26,19 +26,53 @@ function obfuscateGitPass(str: string) {
return gitPass ? str.replace(gitPass, 'GIT_PASS') : str; return gitPass ? str.replace(gitPass, 'GIT_PASS') : str;
} }
const debugMode = !!process.env.DOCUSAURUS_DEPLOY_DEBUG;
// Log executed commands so that user can figure out mistakes on his own // Log executed commands so that user can figure out mistakes on his own
// for example: https://github.com/facebook/docusaurus/issues/3875 // for example: https://github.com/facebook/docusaurus/issues/3875
function shellExecLog(cmd: string) { function exec(cmd: string, options?: {log?: boolean; failfast?: boolean}) {
const log = options?.log ?? true;
const failfast = options?.failfast ?? false;
try { try {
const result = shell.exec(cmd); // TODO migrate to execa(file,[...args]) instead
logger.info`code=${obfuscateGitPass(cmd)} subdue=${`code: ${result.code}`}`; // Use async/await everything
// Avoid execa.command: the args need to be escaped manually
const result = execa.commandSync(cmd);
if (log || debugMode) {
logger.info`code=${obfuscateGitPass(
cmd,
)} subdue=${`code: ${result.exitCode}`}`;
}
if (debugMode) {
console.log(result);
}
if (failfast && result.exitCode !== 0) {
throw new Error(
`Command returned unexpected exitCode ${result.exitCode}`,
);
}
return result; return result;
} catch (err) { } catch (err) {
logger.error`code=${obfuscateGitPass(cmd)}`; throw new Error(
throw err; logger.interpolate`Error while executing command code=${obfuscateGitPass(
cmd,
)}
In CWD code=${process.cwd()}`,
{cause: err},
);
} }
} }
// Execa escape args and add necessary quotes automatically
// When using Execa.command, the args containing spaces must be escaped manually
function escapeArg(arg: string): string {
return arg.replaceAll(' ', '\\ ');
}
function hasGit() {
return exec('git --version').exitCode === 0;
}
export async function deploy( export async function deploy(
siteDirParam: string = '.', siteDirParam: string = '.',
cliOptions: Partial<DeployCLIOptions> = {}, cliOptions: Partial<DeployCLIOptions> = {},
@ -59,19 +93,26 @@ This behavior can have SEO impacts and create relative link issues.
} }
logger.info('Deploy command invoked...'); logger.info('Deploy command invoked...');
if (!shell.which('git')) { if (!hasGit()) {
throw new Error('Git not installed or on the PATH!'); throw new Error('Git not installed or not added to PATH!');
} }
// Source repo is the repo from where the command is invoked // Source repo is the repo from where the command is invoked
const sourceRepoUrl = shell const {stdout} = exec('git remote get-url origin', {
.exec('git remote get-url origin', {silent: true}) log: false,
.stdout.trim(); failfast: true,
});
const sourceRepoUrl = stdout.trim();
// The source branch; defaults to the currently checked out branch // The source branch; defaults to the currently checked out branch
const sourceBranch = const sourceBranch =
process.env.CURRENT_BRANCH ?? process.env.CURRENT_BRANCH ??
shell.exec('git rev-parse --abbrev-ref HEAD', {silent: true}).stdout.trim(); exec('git rev-parse --abbrev-ref HEAD', {
log: false,
failfast: true,
})
?.stdout?.toString()
.trim();
const gitUser = process.env.GIT_USER; const gitUser = process.env.GIT_USER;
@ -116,8 +157,11 @@ This behavior can have SEO impacts and create relative link issues.
const isPullRequest = const isPullRequest =
process.env.CI_PULL_REQUEST ?? process.env.CIRCLE_PULL_REQUEST; process.env.CI_PULL_REQUEST ?? process.env.CIRCLE_PULL_REQUEST;
if (isPullRequest) { if (isPullRequest) {
shell.echo('Skipping deploy on a pull request.'); exec('echo "Skipping deploy on a pull request."', {
shell.exit(0); log: false,
failfast: true,
});
process.exit(0);
} }
// github.io indicates organization repos that deploy via default branch. All // github.io indicates organization repos that deploy via default branch. All
@ -181,7 +225,7 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
// Save the commit hash that triggers publish-gh-pages before checking // Save the commit hash that triggers publish-gh-pages before checking
// out to deployment branch. // out to deployment branch.
const currentCommit = shellExecLog('git rev-parse HEAD').stdout.trim(); const currentCommit = exec('git rev-parse HEAD')?.stdout?.toString().trim();
const runDeploy = async (outputDirectory: string) => { const runDeploy = async (outputDirectory: string) => {
const targetDirectory = cliOptions.targetDir ?? '.'; const targetDirectory = cliOptions.targetDir ?? '.';
@ -189,22 +233,27 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
const toPath = await fs.mkdtemp( const toPath = await fs.mkdtemp(
path.join(os.tmpdir(), `${projectName}-${deploymentBranch}`), path.join(os.tmpdir(), `${projectName}-${deploymentBranch}`),
); );
shell.cd(toPath); process.chdir(toPath);
// Clones the repo into the temp folder and checks out the target branch. // Clones the repo into the temp folder and checks out the target branch.
// If the branch doesn't exist, it creates a new one based on the // If the branch doesn't exist, it creates a new one based on the
// repository default branch. // repository default branch.
if ( if (
shellExecLog( exec(
`git clone --depth 1 --branch ${deploymentBranch} ${deploymentRepoURL} "${toPath}"`, `git clone --depth 1 --branch ${deploymentBranch} ${deploymentRepoURL} ${escapeArg(
).code !== 0 toPath,
)}`,
).exitCode !== 0
) { ) {
shellExecLog(`git clone --depth 1 ${deploymentRepoURL} "${toPath}"`); exec(`git clone --depth 1 ${deploymentRepoURL} ${escapeArg(toPath)}`);
shellExecLog(`git checkout -b ${deploymentBranch}`); exec(`git checkout -b ${deploymentBranch}`);
} }
// Clear out any existing contents in the target directory // Clear out any existing contents in the target directory
shellExecLog(`git rm -rf ${targetDirectory}`); exec(`git rm -rf ${escapeArg(targetDirectory)}`, {
log: false,
failfast: true,
});
const targetPath = path.join(toPath, targetDirectory); const targetPath = path.join(toPath, targetDirectory);
try { try {
@ -213,29 +262,31 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
logger.error`Copying build assets from path=${fromPath} to path=${targetPath} failed.`; logger.error`Copying build assets from path=${fromPath} to path=${targetPath} failed.`;
throw err; throw err;
} }
shellExecLog('git add --all'); exec('git add --all', {failfast: true});
const gitUserName = process.env.GIT_USER_NAME; const gitUserName = process.env.GIT_USER_NAME;
if (gitUserName) { if (gitUserName) {
shellExecLog(`git config user.name "${gitUserName}"`); exec(`git config user.name ${escapeArg(gitUserName)}`, {failfast: true});
} }
const gitUserEmail = process.env.GIT_USER_EMAIL; const gitUserEmail = process.env.GIT_USER_EMAIL;
if (gitUserEmail) { if (gitUserEmail) {
shellExecLog(`git config user.email "${gitUserEmail}"`); exec(`git config user.email ${escapeArg(gitUserEmail)}`, {
failfast: true,
});
} }
const commitMessage = const commitMessage =
process.env.CUSTOM_COMMIT_MESSAGE ?? process.env.CUSTOM_COMMIT_MESSAGE ??
`Deploy website - based on ${currentCommit}`; `Deploy website - based on ${currentCommit}`;
const commitResults = shellExecLog(`git commit -m "${commitMessage}"`); const commitResults = exec(
if ( `git commit -m ${escapeArg(commitMessage)} --allow-empty`,
shellExecLog(`git push --force origin ${deploymentBranch}`).code !== 0 );
) { if (exec(`git push --force origin ${deploymentBranch}`).exitCode !== 0) {
throw new Error( throw new Error(
'Running "git push" command failed. Does the GitHub user account you are using have push access to the repository?', 'Running "git push" command failed. Does the GitHub user account you are using have push access to the repository?',
); );
} else if (commitResults.code === 0) { } else if (commitResults.exitCode === 0) {
// The commit might return a non-zero value when site is up to date. // The commit might return a non-zero value when site is up to date.
let websiteURL = ''; let websiteURL = '';
if (githubHost === 'github.com') { if (githubHost === 'github.com') {
@ -246,8 +297,12 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
// GitHub enterprise hosting. // GitHub enterprise hosting.
websiteURL = `https://${githubHost}/pages/${organizationName}/${projectName}/`; websiteURL = `https://${githubHost}/pages/${organizationName}/${projectName}/`;
} }
shell.echo(`Website is live at "${websiteURL}".`); try {
shell.exit(0); exec(`echo "Website is live at ${websiteURL}."`, {failfast: true});
process.exit(0);
} catch (err) {
throw new Error(`Failed to execute command: ${err}`);
}
} }
}; };

View file

@ -8409,7 +8409,7 @@ execa@5.0.0:
signal-exit "^3.0.3" signal-exit "^3.0.3"
strip-final-newline "^2.0.0" strip-final-newline "^2.0.0"
execa@^5.0.0: execa@5.1.1, execa@^5.0.0:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==