Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cli): rework --cwd flag #5595

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .yarn/versions/e8b1c9c5.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
releases:
"@yarnpkg/cli": patch
"@yarnpkg/core": patch

declined:
- "@yarnpkg/plugin-compat"
- "@yarnpkg/plugin-constraints"
- "@yarnpkg/plugin-dlx"
- "@yarnpkg/plugin-essentials"
- "@yarnpkg/plugin-exec"
- "@yarnpkg/plugin-file"
- "@yarnpkg/plugin-git"
- "@yarnpkg/plugin-github"
- "@yarnpkg/plugin-http"
- "@yarnpkg/plugin-init"
- "@yarnpkg/plugin-interactive-tools"
- "@yarnpkg/plugin-link"
- "@yarnpkg/plugin-nm"
- "@yarnpkg/plugin-npm"
- "@yarnpkg/plugin-npm-cli"
- "@yarnpkg/plugin-pack"
- "@yarnpkg/plugin-patch"
- "@yarnpkg/plugin-pnp"
- "@yarnpkg/plugin-pnpm"
- "@yarnpkg/plugin-stage"
- "@yarnpkg/plugin-typescript"
- "@yarnpkg/plugin-version"
- "@yarnpkg/plugin-workspace-tools"
- "@yarnpkg/builder"
- "@yarnpkg/doctor"
- "@yarnpkg/extensions"
- "@yarnpkg/nm"
- "@yarnpkg/pnpify"
- "@yarnpkg/sdks"
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const mtme = (
const workspacePath = ppath.join(path, workspace as PortablePath);
await xfs.mkdirPromise(workspacePath, {recursive: true});

manifest.name ??= ppath.basename(workspace as PortablePath);

await xfs.writeJsonPromise(ppath.join(workspacePath, Filename.manifest), await deepResolve(manifest));
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,43 @@
export {};
import {Filename, npath, ppath, xfs} from '@yarnpkg/fslib';

const PLUGIN_ENSURE_CWD = `
module.exports = {
name: 'plugin-ensure-cwd',
factory: require => {
const {Command} = require('clipanion');
const {BaseCommand} = require('@yarnpkg/cli');
const {Configuration} = require('@yarnpkg/core');

class EnsureCwdCommand extends BaseCommand {
static paths = [['ensure-cwd']];

async execute() {
const {plugins} = await Configuration.find(this.context.cwd, this.context.plugins);
for (const {commands} of plugins.values()) {
if (commands) {
for (const commandClass of commands) {
const {cwd} = new commandClass();
if (typeof cwd !== 'object' || cwd === null || !cwd[Command.isOption]) {
const path = (commandClass.paths ?? [])[0].join(' ');
throw new Error(\`\${path} doesn't declare a --cwd option\`);
}
}
}
}
}
}

return {
commands: [
EnsureCwdCommand,
],
};
}
};
`;

describe(`Entry`, () => {
describe(`version option`, () => {
describe(`--version`, () => {
test(
`it should print the version from the package.json when given --version`,
makeTemporaryEnv({}, async ({path, run, source}) => {
Expand All @@ -18,4 +54,106 @@ describe(`Entry`, () => {
}),
);
});

describe(`--cwd`, () => {
test(`all commands must support the --cwd flag`, makeTemporaryEnv({}, async ({path, run, source}) => {
await xfs.writeFilePromise(ppath.join(path, `plugin-ensure-cwd.cjs`), PLUGIN_ENSURE_CWD);
await xfs.writeJsonPromise(ppath.join(path, Filename.rc), {
plugins: [`./plugin-ensure-cwd.cjs`],
});

await run(`ensure-cwd`);
}));

test(`should use the specified --cwd (relative path)`, makeTemporaryEnv({}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`--cwd`, `packages`, `exec`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/packages\n`,
});
}));

test(`should use the specified --cwd (absolute path)`, makeTemporaryEnv({}, async ({path, run, source}) => {
await run(`install`);

await makeTemporaryEnv({}, async ({path: path2}) => {
await expect(run(`--cwd`, npath.fromPortablePath(path2), `exec`, `pwd`)).resolves.toMatchObject({
stdout: `${path2}\n`,
});
});
}));

test(`should use the specified --cwd (multiple positions)`, makeTemporaryEnv({}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`--cwd`, `packages`, `exec`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/packages\n`,
});
await expect(run(`exec`, `--cwd`, `packages`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/packages\n`,
});
}));

test(`should use the specified --cwd (bound option)`, makeTemporaryEnv({}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`exec`, `--cwd=packages`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/packages\n`,
});
}));

test(`should use the specified --cwd (repeated option)`, makeTemporaryEnv({}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`--cwd=modules`, `exec`, `--cwd=packages`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/packages\n`,
});
}));

test(`should use the specified --cwd (composed with leading folder argument)`, makeTemporaryEnv({}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`./foo`, `--cwd=bar`, `exec`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/foo/bar\n`,
});
await expect(run(`--cwd=bar`, `./foo`, `exec`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/bar/foo\n`,
});
await expect(run(`--cwd=bar`, `./foo`, `exec`, `--cwd=baz`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/bar/foo/baz\n`,
});
}));

test(`should use the specified --cwd (composed with yarn workspace)`, makeTemporaryMonorepoEnv({
workspaces: [`foo`],
}, {
foo: {},
}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`workspace`, `foo`, `exec`, `--cwd=bar`, `pwd`)).resolves.toMatchObject({
stdout: `${path}/foo/bar\n`,
});
}));

test(`should use the specified --cwd (composed with yarn workspaces foreach)`, makeTemporaryMonorepoEnv({
workspaces: [`foo`, `bar`, `baz`],
}, {
foo: {},
bar: {},
baz: {},
}, async ({path, run, source}) => {
await run(`install`);

await expect(run(`workspaces`, `foreach`, `exec`, `--cwd=qux`, `pwd`)).resolves.toMatchObject({
stdout: [
`${path}/qux`,
`${path}/bar/qux`,
`${path}/baz/qux`,
`${path}/foo/qux`,
`Done\n`,
].join(`\n`),
});
}));
});
});
6 changes: 0 additions & 6 deletions packages/docusaurus/static/configuration/yarnrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,6 @@
"format": "uri",
"examples": ["http://proxy:4040"]
},
"ignoreCwd": {
"_package": "@yarnpkg/core",
"description": "If true, the `--cwd` flag will be ignored.",
"type": "boolean",
"default": false
},
"ignorePath": {
"_package": "@yarnpkg/core",
"description": "If true, the local executable will be ignored when using the global one.",
Expand Down
6 changes: 0 additions & 6 deletions packages/gatsby/static/configuration/yarnrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,6 @@
"format": "uri",
"examples": ["http://proxy:4040"]
},
"ignoreCwd": {
"_package": "@yarnpkg/core",
"description": "If true, the `--cwd` flag will be ignored.",
"type": "boolean",
"default": false
},
"ignorePath": {
"_package": "@yarnpkg/core",
"description": "If true, the local executable will be ignored when using the global one.",
Expand Down
25 changes: 3 additions & 22 deletions packages/yarnpkg-cli/sources/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {Configuration, CommandContext, PluginConfiguration, TelemetryManager, semverUtils, miscUtils} from '@yarnpkg/core';
import {PortablePath, npath, xfs} from '@yarnpkg/fslib';
import {PortablePath, npath, ppath, xfs} from '@yarnpkg/fslib';
import {execFileSync} from 'child_process';
import {isCI} from 'ci-info';
import {Cli, UsageError} from 'clipanion';
import {realpathSync} from 'fs';

import {pluginCommands} from './pluginCommands';

Expand All @@ -21,7 +20,6 @@ function runBinary(path: PortablePath) {
env: {
...process.env,
YARN_IGNORE_PATH: `1`,
YARN_IGNORE_CWD: `1`,
},
});
} else {
Expand All @@ -30,7 +28,6 @@ function runBinary(path: PortablePath) {
env: {
...process.env,
YARN_IGNORE_PATH: `1`,
YARN_IGNORE_CWD: `1`,
},
});
}
Expand Down Expand Up @@ -75,7 +72,6 @@ export async function main({binaryVersion, pluginConfiguration}: {binaryVersion:

const yarnPath = configuration.get(`yarnPath`);
const ignorePath = configuration.get(`ignorePath`);
const ignoreCwd = configuration.get(`ignoreCwd`);

const selfPath = npath.toPortablePath(npath.resolve(process.argv[1]));

Expand All @@ -93,9 +89,8 @@ export async function main({binaryVersion, pluginConfiguration}: {binaryVersion:
);

// Avoid unnecessary spawn when run directly
if (!ignorePath && !ignoreCwd && await isSameBinary()) {
if (!ignorePath && await isSameBinary()) {
process.env.YARN_IGNORE_PATH = `1`;
process.env.YARN_IGNORE_CWD = `1`;

await exec(cli);
return;
Expand Down Expand Up @@ -130,7 +125,7 @@ export async function main({binaryVersion, pluginConfiguration}: {binaryVersion:
}

const context = {
cwd: npath.toPortablePath(process.cwd()),
cwd: ppath.cwd(),
plugins: pluginConfiguration,
quiet: false,
stdin: process.stdin,
Expand All @@ -142,20 +137,6 @@ export async function main({binaryVersion, pluginConfiguration}: {binaryVersion:
if (!command.help)
Configuration.telemetry?.reportCommandName(command.path.join(` `));

// @ts-expect-error: The cwd is a global option defined by BaseCommand
const cwd: string | undefined = command.cwd;

if (typeof cwd !== `undefined` && !ignoreCwd) {
const iAmHere = realpathSync(process.cwd());
const iShouldBeHere = realpathSync(cwd);

if (iAmHere !== iShouldBeHere) {
process.chdir(cwd);
await run();
return;
}
}

await cli.runExit(command, context);
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/yarnpkg-cli/sources/tools/BaseCommand.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import {CommandContext} from '@yarnpkg/core';
import {npath, ppath} from '@yarnpkg/fslib';
import {Command, Option} from 'clipanion';

export abstract class BaseCommand extends Command<CommandContext> {
cwd = Option.String(`--cwd`, {hidden: true});

abstract execute(): Promise<number | void>;

validateAndExecute() {
if (typeof this.cwd !== `undefined`)
this.context.cwd = ppath.resolve(this.context.cwd, npath.toPortablePath(this.cwd));
Comment on lines +11 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems something between a bug and a breaking change: it won't read the content from the .yarnrc.yml in the target folder anymore, same with everything else we do before actually running the command (like the Configuration.find calls).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, perhaps we can move all this logic inside validateAndExecute, rather than keep it inside main 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 You're right, but that was always the case for the other commands that modify the cwd (e.g. yarn ./foo [...], yarn workspace, yarn workspaces foreach). I'd agree it's a bug, but at least this PR makes it consistent across all commands 😄

The things affected by this are yarnPath, enableTelemetry and plugins (which will be ignored in the target --cwd).

yarnPath and telemetry logic could be moved to BaseCommand, but the CLI needs to know which plugins to load before it can run.

In this case, I'd rather have both yarn --cwd foo and yarn ./foo be broken but do the same thing. It would be weirdly inconsistent for one of them to use the right path and load the right plugins while the other doesn't.


return super.validateAndExecute();
}
}
16 changes: 7 additions & 9 deletions packages/yarnpkg-core/sources/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ const IGNORED_ENV_VARIABLES = new Set([

// "YARN_REGISTRY", read by yarn 1.x, prevents yarn 2+ installations if set
`registry`,

// "ignoreCwd" was previously used to skip extra chdir calls in Yarn Modern when `--cwd` was used.
// It needs to be ignored because it's set by the parent process which could be anything.
`ignoreCwd`,
]);

export const TAG_REGEXP = /^(?!v)[a-z0-9._-]+$/i;
Expand Down Expand Up @@ -198,11 +202,6 @@ export const coreDefinitions: {[coreSettingName: string]: SettingsDefinition} =
type: SettingsType.BOOLEAN,
default: false,
},
ignoreCwd: {
description: `If true, the \`--cwd\` flag will be ignored`,
type: SettingsType.BOOLEAN,
default: false,
},

// Settings related to the package manager internal names
globalFolder: {
Expand Down Expand Up @@ -597,7 +596,6 @@ export interface ConfigurationValueMap {

yarnPath: PortablePath | null;
ignorePath: boolean;
ignoreCwd: boolean;

globalFolder: PortablePath;
cacheFolder: PortablePath;
Expand Down Expand Up @@ -1079,8 +1077,8 @@ export class Configuration {

const allCoreFieldKeys = new Set(Object.keys(coreDefinitions));

const pickPrimaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles}: CoreFields) => ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles});
const pickSecondaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles, ...rest}: CoreFields) => {
const pickPrimaryCoreFields = ({yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles}: CoreFields) => ({yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles});
const pickSecondaryCoreFields = ({yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles, ...rest}: CoreFields) => {
const secondaryCoreFields: CoreFields = {};
for (const [key, value] of Object.entries(rest))
if (allCoreFieldKeys.has(key))
Expand All @@ -1089,7 +1087,7 @@ export class Configuration {
return secondaryCoreFields;
};

const pickPluginFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, ...rest}: CoreFields) => {
const pickPluginFields = ({yarnPath, ignorePath, lockfileFilename, ...rest}: CoreFields) => {
const pluginFields: any = {};
for (const [key, value] of Object.entries(rest))
if (!allCoreFieldKeys.has(key))
Expand Down