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/utils": "3.7.0",
"commander": "^5.1.0",
"execa": "5.1.1",
"fs-extra": "^11.1.1",
"lodash": "^4.17.21",
"prompts": "^2.4.2",
"semver": "^7.5.4",
"shelljs": "^0.8.5",
"supports-color": "^9.4.0",
"tslib": "^2.6.0"
},

View file

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

View file

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

View file

@ -9,12 +9,13 @@ import {jest} from '@jest/globals';
import fs from 'fs-extra';
import path from 'path';
import {createTempRepo} from '@testing-utils/git';
import shell from 'shelljs';
import execa from 'execa';
import {
getGitLastUpdate,
LAST_UPDATE_FALLBACK,
readLastUpdateData,
} from '@docusaurus/utils';
} from '../lastUpdateUtils';
describe('getGitLastUpdate', () => {
const {repoDir} = createTempRepo();
@ -69,7 +70,10 @@ describe('getGitLastUpdate', () => {
});
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
.spyOn(console, 'warn')
.mockImplementation(() => {});

View file

@ -8,9 +8,15 @@
import path from 'path';
import fs from 'fs-extra';
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 user won't install Git in the middle of a build anyway...
@ -123,27 +129,13 @@ export async function getFileCommitDate(
file,
)}"`;
const result = await new Promise<{
code: number;
stdout: string;
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});
},
);
const result = await execa(command, {
cwd: path.dirname(file),
shell: true,
});
if (result.code !== 0) {
if (result.exitCode !== 0) {
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 path from 'path';
import {createRequire} from 'module';
import shell from 'shelljs';
import execa from 'execa';
import {logger} from '@docusaurus/logger';
import semver from 'semver';
import updateNotifier from 'update-notifier';
@ -111,8 +111,8 @@ export default async function beforeCli() {
return undefined;
}
const yarnVersionResult = shell.exec('yarn --version', {silent: true});
if (yarnVersionResult?.code === 0) {
const yarnVersionResult = await execa.command('yarn --version');
if (yarnVersionResult.exitCode === 0) {
const majorVersion = parseInt(
yarnVersionResult.stdout?.trim().split('.')[0] ?? '',
10,

View file

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

View file

@ -9,7 +9,7 @@ import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import logger from '@docusaurus/logger';
import shell from 'shelljs';
import execa from 'execa';
import {hasSSHProtocol, buildSshUrl, buildHttpsUrl} from '@docusaurus/utils';
import {loadContext, type LoadContextParams} from '../server/site';
import {build} from './build/build';
@ -26,19 +26,53 @@ function obfuscateGitPass(str: string) {
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
// 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 {
const result = shell.exec(cmd);
logger.info`code=${obfuscateGitPass(cmd)} subdue=${`code: ${result.code}`}`;
// TODO migrate to execa(file,[...args]) instead
// 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;
} catch (err) {
logger.error`code=${obfuscateGitPass(cmd)}`;
throw err;
throw new Error(
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(
siteDirParam: string = '.',
cliOptions: Partial<DeployCLIOptions> = {},
@ -59,19 +93,26 @@ This behavior can have SEO impacts and create relative link issues.
}
logger.info('Deploy command invoked...');
if (!shell.which('git')) {
throw new Error('Git not installed or on the PATH!');
if (!hasGit()) {
throw new Error('Git not installed or not added to PATH!');
}
// Source repo is the repo from where the command is invoked
const sourceRepoUrl = shell
.exec('git remote get-url origin', {silent: true})
.stdout.trim();
const {stdout} = exec('git remote get-url origin', {
log: false,
failfast: true,
});
const sourceRepoUrl = stdout.trim();
// The source branch; defaults to the currently checked out branch
const sourceBranch =
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;
@ -116,8 +157,11 @@ This behavior can have SEO impacts and create relative link issues.
const isPullRequest =
process.env.CI_PULL_REQUEST ?? process.env.CIRCLE_PULL_REQUEST;
if (isPullRequest) {
shell.echo('Skipping deploy on a pull request.');
shell.exit(0);
exec('echo "Skipping deploy on a pull request."', {
log: false,
failfast: true,
});
process.exit(0);
}
// 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
// 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 targetDirectory = cliOptions.targetDir ?? '.';
@ -189,22 +233,27 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
const toPath = await fs.mkdtemp(
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.
// If the branch doesn't exist, it creates a new one based on the
// repository default branch.
if (
shellExecLog(
`git clone --depth 1 --branch ${deploymentBranch} ${deploymentRepoURL} "${toPath}"`,
).code !== 0
exec(
`git clone --depth 1 --branch ${deploymentBranch} ${deploymentRepoURL} ${escapeArg(
toPath,
)}`,
).exitCode !== 0
) {
shellExecLog(`git clone --depth 1 ${deploymentRepoURL} "${toPath}"`);
shellExecLog(`git checkout -b ${deploymentBranch}`);
exec(`git clone --depth 1 ${deploymentRepoURL} ${escapeArg(toPath)}`);
exec(`git checkout -b ${deploymentBranch}`);
}
// 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);
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.`;
throw err;
}
shellExecLog('git add --all');
exec('git add --all', {failfast: true});
const gitUserName = process.env.GIT_USER_NAME;
if (gitUserName) {
shellExecLog(`git config user.name "${gitUserName}"`);
exec(`git config user.name ${escapeArg(gitUserName)}`, {failfast: true});
}
const gitUserEmail = process.env.GIT_USER_EMAIL;
if (gitUserEmail) {
shellExecLog(`git config user.email "${gitUserEmail}"`);
exec(`git config user.email ${escapeArg(gitUserEmail)}`, {
failfast: true,
});
}
const commitMessage =
process.env.CUSTOM_COMMIT_MESSAGE ??
`Deploy website - based on ${currentCommit}`;
const commitResults = shellExecLog(`git commit -m "${commitMessage}"`);
if (
shellExecLog(`git push --force origin ${deploymentBranch}`).code !== 0
) {
const commitResults = exec(
`git commit -m ${escapeArg(commitMessage)} --allow-empty`,
);
if (exec(`git push --force origin ${deploymentBranch}`).exitCode !== 0) {
throw new Error(
'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.
let websiteURL = '';
if (githubHost === 'github.com') {
@ -246,8 +297,12 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
// GitHub enterprise hosting.
websiteURL = `https://${githubHost}/pages/${organizationName}/${projectName}/`;
}
shell.echo(`Website is live at "${websiteURL}".`);
shell.exit(0);
try {
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"
strip-final-newline "^2.0.0"
execa@^5.0.0:
execa@5.1.1, execa@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==