diff --git a/packages/create-docusaurus/package.json b/packages/create-docusaurus/package.json index 3d1399ae1d..5ebe1e4b18 100755 --- a/packages/create-docusaurus/package.json +++ b/packages/create-docusaurus/package.json @@ -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" }, diff --git a/packages/create-docusaurus/src/index.ts b/packages/create-docusaurus/src/index.ts index 2ed0815367..f38f0f2287 100755 --- a/packages/create-docusaurus/src/index.ts +++ b/packages/create-docusaurus/src/index.ts @@ -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 { - 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: diff --git a/packages/docusaurus-plugin-content-docs/package.json b/packages/docusaurus-plugin-content-docs/package.json index bbfdd49432..f9b228f5c9 100644 --- a/packages/docusaurus-plugin-content-docs/package.json +++ b/packages/docusaurus-plugin-content-docs/package.json @@ -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", diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index f4691afedc..7dde60ae3a 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -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", diff --git a/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts b/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts index 14e907e994..48dbe85220 100644 --- a/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts @@ -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(() => {}); diff --git a/packages/docusaurus-utils/src/gitUtils.ts b/packages/docusaurus-utils/src/gitUtils.ts index 39a3ad754a..104bf73634 100644 --- a/packages/docusaurus-utils/src/gitUtils.ts +++ b/packages/docusaurus-utils/src/gitUtils.ts @@ -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}`, ); } diff --git a/packages/docusaurus/bin/beforeCli.mjs b/packages/docusaurus/bin/beforeCli.mjs index ca81a6ab45..4662c38b43 100644 --- a/packages/docusaurus/bin/beforeCli.mjs +++ b/packages/docusaurus/bin/beforeCli.mjs @@ -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, diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 7e4804593c..41c9124f8a 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -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", diff --git a/packages/docusaurus/src/commands/deploy.ts b/packages/docusaurus/src/commands/deploy.ts index 4b91ce7f78..2891ca9bd4 100644 --- a/packages/docusaurus/src/commands/deploy.ts +++ b/packages/docusaurus/src/commands/deploy.ts @@ -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 = {}, @@ -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}`); + } } }; diff --git a/yarn.lock b/yarn.lock index e8ca49a55e..32055d13f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==