diff --git a/README.md b/README.md index e2adeb949..6e3ead879 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,16 @@ The utility commands detailed in the next section. ## Utility Commands +Add `--verbose` to a Corepack utility command to print diagnostics on stderr, +for example `corepack cache clean --verbose`. Cache cleaning reports its target +directory and whether that cache exists; other commands include their package +manager resolution and download diagnostics. Normal stdout is unchanged. + +To enable Corepack diagnostics when running a package manager, put the option +before its name: `corepack --verbose pnpm install`. Arguments after the package +manager name are passed to that manager. Setting `DEBUG=corepack` remains +supported. + ### `corepack [@] [... args]` This meta-command runs the specified package manager in the local folder. You diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 9c7214c08..df55fcc52 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -1,11 +1,33 @@ -import {Command, UsageError} from 'clipanion'; +import {Command, Option, UsageError} from 'clipanion'; import type {PreparedPackageManagerInfo} from '../Engine.ts'; import * as corepackUtils from '../corepackUtils.ts'; +import * as debugUtils from '../debugUtils.ts'; import type {Context} from '../main.ts'; import * as specUtils from '../specUtils.ts'; export abstract class BaseCommand extends Command { + verbose = Option.Boolean(`--verbose`, false, { + description: `Print Corepack diagnostic information on stderr`, + }); + + async validateAndExecute() { + const wasEnabled = debugUtils.log.enabled; + if (this.verbose) + debugUtils.log.enabled = true; + + try { + if (this.path.length > 0) + debugUtils.log(`Running ${this.path.join(` `)}`); + + return await super.validateAndExecute(); + } finally { + if (this.verbose) { + debugUtils.log.enabled = wasEnabled; + } + } + } + async resolvePatternsToDescriptors({patterns}: {patterns: Array}) { const resolvedSpecs = patterns.map(pattern => specUtils.parseSpec(pattern, `CLI arguments`, {enforceExactVersion: false})); diff --git a/sources/commands/Cache.ts b/sources/commands/Cache.ts index 6d67b8a59..121e33832 100644 --- a/sources/commands/Cache.ts +++ b/sources/commands/Cache.ts @@ -1,10 +1,13 @@ import {Command} from 'clipanion'; import fs from 'fs'; +import * as debugUtils from '../debugUtils.ts'; import {getInstallFolder} from '../folderUtils.ts'; -import type {Context} from '../main.ts'; +import {isNodeError} from '../nodeUtils.ts'; -export class CacheCommand extends Command { +import {BaseCommand} from './Base.ts'; + +export class CacheCommand extends BaseCommand { static paths = [ [`cache`, `clean`], [`cache`, `clear`], @@ -18,6 +21,19 @@ export class CacheCommand extends Command { }); async execute() { - await fs.promises.rm(getInstallFolder(), {recursive: true, force: true}); + const folder = getInstallFolder(); + try { + await fs.promises.lstat(folder); + } catch (error) { + if (isNodeError(error) && error.code === `ENOENT`) { + debugUtils.log(`Package manager cache does not exist at ${folder}`); + return; + } + throw error; + } + + debugUtils.log(`Removing package manager cache at ${folder}`); + await fs.promises.rm(folder, {recursive: true, force: true}); + debugUtils.log(`Finished cleaning package manager cache at ${folder}`); } } diff --git a/sources/commands/Disable.ts b/sources/commands/Disable.ts index 8d99c5494..9045e3b37 100644 --- a/sources/commands/Disable.ts +++ b/sources/commands/Disable.ts @@ -4,11 +4,12 @@ import path from 'p import which from 'which'; import * as corepackUtils from '../corepackUtils.ts'; -import type {Context} from '../main.ts'; import type {NodeError} from '../nodeUtils.ts'; import {isSupportedPackageManager, SupportedPackageManagerSetWithoutNpm} from '../types.ts'; -export class DisableCommand extends Command { +import {BaseCommand} from './Base.ts'; + +export class DisableCommand extends BaseCommand { static paths = [ [`disable`], ]; diff --git a/sources/commands/Enable.ts b/sources/commands/Enable.ts index eba073176..6748e1e1e 100644 --- a/sources/commands/Enable.ts +++ b/sources/commands/Enable.ts @@ -5,10 +5,11 @@ import path from 'p import which from 'which'; import * as corepackUtils from '../corepackUtils.ts'; -import type {Context} from '../main.ts'; import {isSupportedPackageManager, SupportedPackageManagerSetWithoutNpm} from '../types.ts'; -export class EnableCommand extends Command { +import {BaseCommand} from './Base.ts'; + +export class EnableCommand extends BaseCommand { static paths = [ [`enable`], ]; diff --git a/sources/commands/Verbose.ts b/sources/commands/Verbose.ts new file mode 100644 index 000000000..787f713f1 --- /dev/null +++ b/sources/commands/Verbose.ts @@ -0,0 +1,31 @@ +import {Command, Option} from 'clipanion'; + +import {runMain} from '../main.ts'; + +import {BaseCommand} from './Base.ts'; + +export class VerboseCommand extends BaseCommand { + static paths = [[`--verbose`]]; + + static usage = Command.Usage({ + description: `Run a command with Corepack diagnostics`, + details: ` + Place \`--verbose\` before a package manager name to print Corepack + diagnostics on stderr. Arguments after the package manager name are + passed to that manager. + `, + examples: [[ + `Show Corepack diagnostics when running pnpm`, + `corepack --verbose pnpm install`, + ]], + }); + + // The flag is consumed as this command's path. + verbose = true; + + command = Option.Proxy({name: `command`}); + + async execute() { + await runMain(this.command); + } +} diff --git a/sources/commands/deprecated/Hydrate.ts b/sources/commands/deprecated/Hydrate.ts index 200e282fb..5ffa63f05 100644 --- a/sources/commands/deprecated/Hydrate.ts +++ b/sources/commands/deprecated/Hydrate.ts @@ -1,12 +1,12 @@ -import {Command, Option, UsageError} from 'clipanion'; -import {mkdir} from 'fs/promises'; -import path from 'path'; +import {Option, UsageError} from 'clipanion'; +import {mkdir} from 'fs/promises'; +import path from 'path'; -import * as folderUtils from '../../folderUtils.ts'; -import type {Context} from '../../main.ts'; -import {isSupportedPackageManager} from '../../types.ts'; +import * as folderUtils from '../../folderUtils.ts'; +import {isSupportedPackageManager} from '../../types.ts'; +import {BaseCommand} from '../Base.ts'; -export class HydrateCommand extends Command { +export class HydrateCommand extends BaseCommand { static paths = [ [`hydrate`], ]; diff --git a/sources/commands/deprecated/Prepare.ts b/sources/commands/deprecated/Prepare.ts index 02f2864b4..7091db9e7 100644 --- a/sources/commands/deprecated/Prepare.ts +++ b/sources/commands/deprecated/Prepare.ts @@ -1,13 +1,13 @@ -import {Command, Option, UsageError} from 'clipanion'; -import {mkdir} from 'fs/promises'; -import path from 'path'; +import {Option, UsageError} from 'clipanion'; +import {mkdir} from 'fs/promises'; +import path from 'path'; -import * as folderUtils from '../../folderUtils.ts'; -import type {Context} from '../../main.ts'; -import * as specUtils from '../../specUtils.ts'; -import type {Descriptor} from '../../types.ts'; +import * as folderUtils from '../../folderUtils.ts'; +import * as specUtils from '../../specUtils.ts'; +import type {Descriptor} from '../../types.ts'; +import {BaseCommand} from '../Base.ts'; -export class PrepareCommand extends Command { +export class PrepareCommand extends BaseCommand { static paths = [ [`prepare`], ]; diff --git a/sources/main.ts b/sources/main.ts index e623af1f5..03beecfc3 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -13,6 +13,7 @@ import {InstallLocalCommand} from './commands/InstallLocal.ts'; import {PackCommand} from './commands/Pack.ts'; import {UpCommand} from './commands/Up.ts'; import {UseCommand} from './commands/Use.ts'; +import {VerboseCommand} from './commands/Verbose.ts'; import {HydrateCommand} from './commands/deprecated/Hydrate.ts'; import {PrepareCommand} from './commands/deprecated/Prepare.ts'; @@ -70,6 +71,7 @@ export async function runMain(argv: Array) { cli.register(PackCommand); cli.register(UpCommand); cli.register(UseCommand); + cli.register(VerboseCommand); // Deprecated commands cli.register(HydrateCommand); diff --git a/tests/Use.test.ts b/tests/Use.test.ts index 6dd37939e..61753e391 100644 --- a/tests/Use.test.ts +++ b/tests/Use.test.ts @@ -54,7 +54,7 @@ describe(`UseCommand`, () => { await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ exitCode: 1, stderr: ``, - stdout: `Installing yarn@1.22.4 in the project...\nUsage Error: The requested version of yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18 does not match the devEngines specification (yarn@2.x)\n\n$ corepack use \n`, + stdout: `Installing yarn@1.22.4 in the project...\nUsage Error: The requested version of yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18 does not match the devEngines specification (yarn@2.x)\n\n$ corepack use [--verbose] \n`, }); // Should accept setting to a compatible version: diff --git a/tests/Verbose.test.ts b/tests/Verbose.test.ts new file mode 100644 index 000000000..d16dc8c5b --- /dev/null +++ b/tests/Verbose.test.ts @@ -0,0 +1,134 @@ +import {npath, ppath, xfs} from '@yarnpkg/fslib'; +import type {PortablePath} from '@yarnpkg/fslib'; +import {beforeEach, describe, expect, it} from 'vitest'; + +import {runCli} from './_runCli.ts'; + +beforeEach(async () => { + const home = await xfs.mktempPromise(); + process.env.COREPACK_HOME = npath.fromPortablePath(home); + process.env.COREPACK_DEFAULT_TO_LATEST = `0`; + process.env.COREPACK_ENABLE_NETWORK = `0`; + return () => xfs.removePromise(home, {recursive: true}); +}); + +async function cachePackageManager(cwd: PortablePath) { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {packageManager: `pnpm@1.0.0`}); + const folder = ppath.join(npath.toPortablePath(process.env.COREPACK_HOME!), `v1`, `pnpm`, `1.0.0`); + await xfs.mkdirpPromise(folder); + await xfs.writeJsonPromise(ppath.join(folder, `.corepack`), { + locator: {name: `pnpm`, reference: `1.0.0`}, + bin: {pnpm: `pnpm.js`}, + hash: `sha512.abc`, + }); + await xfs.writeFilePromise(ppath.join(folder, `pnpm.js`), `console.log(JSON.stringify(process.argv.slice(2))); if (process.argv.includes('--fail')) process.exitCode = 23;\n`); +} + +describe(`verbose diagnostics`, () => { + it(`keeps the global command help available`, async () => { + await xfs.mktempPromise(async cwd => { + const result = await runCli(cwd, [`--help`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`corepack cache clean`); + expect(result.stdout).toContain(`corepack use`); + expect(result.stderr).toBe(``); + }); + }); + + it(`supports the version command`, async () => { + await xfs.mktempPromise(async cwd => { + const plain = await runCli(cwd, [`--version`]); + const verbose = await runCli(cwd, [`--verbose`, `--version`]); + expect(verbose.exitCode).toBe(0); + expect(verbose.stdout).toBe(plain.stdout); + }); + }); + + it.each([ + {command: [`cache`, `clean`], verbose: false}, + {command: [`cache`, `clear`, `--no-verbose`], verbose: false}, + {command: [`cache`, `clean`, `--verbose`], verbose: true}, + {command: [`--verbose`, `cache`, `clear`], verbose: true}, + ])(`cleans the cache with $command`, async ({command, verbose}) => { + await xfs.mktempPromise(async cwd => { + const folder = ppath.join(npath.toPortablePath(process.env.COREPACK_HOME!), `v1`); + await xfs.mkdirpPromise(folder); + await xfs.writeFilePromise(ppath.join(folder, `cached-file`), `cached`); + + const result = await runCli(cwd, command); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(``); + if (verbose) { + expect(result.stderr).toContain(`Removing package manager cache at ${npath.fromPortablePath(folder)}`); + expect(result.stderr).toContain(`Finished cleaning package manager cache`); + } else { + expect(result.stderr).toBe(``); + } + await expect(xfs.existsPromise(folder)).resolves.toBe(false); + }); + }); + + it(`reports when the cache is already absent`, async () => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`cache`, `clean`, `--verbose`])).resolves.toMatchObject({ + exitCode: 0, + stdout: ``, + stderr: expect.stringContaining(`Package manager cache does not exist at`), + }); + }); + }); + + it(`preserves DEBUG-based diagnostics`, async () => { + process.env.DEBUG = `corepack`; + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`cache`, `clean`])).resolves.toMatchObject({ + exitCode: 0, + stdout: ``, + stderr: expect.stringContaining(`Package manager cache does not exist at`), + }); + }); + }); + + it.each([true, false])(`forwards package manager arguments (Corepack verbose: %s)`, async verbose => { + await xfs.mktempPromise(async cwd => { + await cachePackageManager(cwd); + const args = [`--verbose`, `--filter`, `@scope/example`, `install`]; + const result = await runCli(cwd, [...(verbose ? [`--verbose`] : []), `pnpm`, ...args]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(`${JSON.stringify(args)}\n`); + if (verbose) { + expect(result.stderr).toContain(`corepack`); + expect(result.stderr).toContain(`Reusing pnpm@1.0.0`); + } else { + expect(result.stderr).toBe(``); + } + }); + }); + + it(`preserves a package manager's exit code`, async () => { + await xfs.mktempPromise(async cwd => { + await cachePackageManager(cwd); + await expect(runCli(cwd, [`--verbose`, `pnpm`, `--fail`])).resolves.toMatchObject({exitCode: 23}); + }); + }); + + it(`preserves utility command failures`, async () => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, [`--verbose`, `not-a-command`])).resolves.toMatchObject({exitCode: 1}); + }); + }); + + it(`keeps --json output unchanged`, async () => { + await xfs.mktempPromise(async cwd => { + await cachePackageManager(cwd); + const args = [`pack`, `pnpm@1.0.0`, `--json`]; + const plain = await runCli(cwd, args); + const verbose = await runCli(cwd, [...args, `--verbose`]); + expect(plain.exitCode).toBe(0); + expect(verbose.exitCode).toBe(0); + expect(verbose.stdout).toBe(plain.stdout); + expect(verbose.stderr).toContain(`Reusing pnpm@1.0.0`); + await expect(xfs.existsPromise(ppath.join(cwd, `corepack.tgz`))).resolves.toBe(true); + }); + }); +});