[cli] Add support for vc --cwd <dir> (#7577)

`vc`'s default command is `deploy`, which can lead to ambiguous cli invocations when running `vc dir-or-command` like:

```
$  vc list
Vercel CLI 23.1.2
Error! The supplied argument "list" is ambiguous.
If you wish to deploy the subdirectory "list", first run "cd list".
```

when run in a directory that contains a subdirectory "list". This conflict will happen with any current and future commands, like `vc build`.

In order to make sure the CLI can be invoked either way, this PR deprecates the default behavior. Going forward, a user would see the following.

**Conflicting Command, Run Command**

```bash
$ vc list
# warning: Did you mean to deploy the subdirectory "list"? Use `vc --cwd list` instead.
# ... runs the `list` command
```

**Conflicting Command, Deploy Directory**

```bash
$ vc --cwd list
# ... deploy as normal
```

---

Card: https://linear.app/vercel/issue/BUI-33/prevent-ambiguous-vc-command-oror-dir
This commit is contained in:
Sean Massa
2022-03-30 16:06:16 -05:00
committed by GitHub
parent d9e5342eba
commit e40eecafc9
11 changed files with 169 additions and 77 deletions

View File

@@ -42,6 +42,7 @@ export const help = () => `
-h, --help Output usage information
-v, --version Output the version number
--cwd Current working directory
-V, --platform-version Set the platform version to deploy to
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
'FILE'

View File

@@ -12,7 +12,7 @@ try {
}
import { join } from 'path';
import { existsSync, lstatSync } from 'fs';
import { existsSync } from 'fs';
import sourceMap from '@zeit/source-map-support';
import { mkdirp } from 'fs-extra';
import chalk from 'chalk';
@@ -136,6 +136,11 @@ const main = async () => {
return 1;
}
const cwd = argv['--cwd'];
if (cwd) {
process.chdir(cwd);
}
// Print update information, if available
if (notifier.update && notifier.update.latest !== pkg.version && isTTY) {
const { latest } = notifier.update;
@@ -386,34 +391,11 @@ const main = async () => {
GLOBAL_COMMANDS.has(targetOrSubcommand) ||
commands.has(targetOrSubcommand);
if (targetPathExists && subcommandExists) {
const fileType = lstatSync(targetPath).isDirectory()
? 'subdirectory'
: 'file';
const plural = targetOrSubcommand + 's';
const singular = targetOrSubcommand.endsWith('s')
? targetOrSubcommand.slice(0, -1)
: '';
let alternative = '';
if (commands.has(plural)) {
alternative = plural;
} else if (commands.has(singular)) {
alternative = singular;
}
console.error(
error(
`The supplied argument ${param(targetOrSubcommand)} is ambiguous.` +
`\nIf you wish to deploy the ${fileType} ${param(
targetOrSubcommand
)}, first run "cd ${targetOrSubcommand}". ` +
(alternative
? `\nIf you wish to use the subcommand ${param(
targetOrSubcommand
)}, use ${param(alternative)} instead.`
: '')
)
if (targetPathExists && subcommandExists && !argv['--cwd']) {
output.warn(
`Did you mean to deploy the subdirectory "${targetOrSubcommand}"? ` +
`Use \`vc --cwd ${targetOrSubcommand}\` instead.`
);
return 1;
}
if (subcommandExists) {

View File

@@ -25,7 +25,9 @@ const ARG_COMMON = {
'--api': String,
'--target': String
'--target': String,
'--cwd': String,
};
export default () => ARG_COMMON;

View File

@@ -26,7 +26,7 @@ export default async function addEnvRecord(
target: targets,
gitBranch: gitBranch || undefined,
};
const url = `/v7/projects/${projectId}/env`;
const url = `/v8/projects/${projectId}/env`;
await client.fetch(url, {
method: 'POST',
body: JSON.stringify(body),

View File

@@ -32,7 +32,7 @@ export default async function getEnvRecords(
query.set('decrypt', decrypt.toString());
}
const url = `/v7/projects/${projectId}/env?${query}`;
const url = `/v8/projects/${projectId}/env?${query}`;
return client.fetch<{ envs: ProjectEnvVariable[] }>(url);
}

View File

@@ -10,7 +10,7 @@ export default async function removeEnvRecord(
): Promise<void> {
output.debug(`Removing Environment Variable ${env.key}`);
const urlProject = `/v7/projects/${projectId}/env/${env.id}`;
const urlProject = `/v8/projects/${projectId}/env/${env.id}`;
await client.fetch<ProjectEnvVariable>(urlProject, {
method: 'DELETE',

View File

@@ -19,19 +19,24 @@ export default async function inputProject(
// attempt to auto-detect a project to link
let detectedProject = null;
output.spinner('Searching for existing projects…', 1000);
try {
const [project, slugifiedProject] = await Promise.all([
getProjectByIdOrName(client, detectedProjectName, org.id),
slugifiedName !== detectedProjectName
? getProjectByIdOrName(client, slugifiedName, org.id)
: null,
]);
detectedProject = !(project instanceof ProjectNotFound)
? project
: !(slugifiedProject instanceof ProjectNotFound)
? slugifiedProject
: null;
} catch (error) {}
if (detectedProject && !detectedProject.id) {
throw new Error(`Detected linked project does not have "id".`);
}
output.stopSpinner();
if (autoConfirm) {

View File

@@ -9,7 +9,7 @@ export default async function getProjectByNameOrId(
) {
try {
const project = await client.fetch<Project>(
`/projects/${encodeURIComponent(projectNameOrId)}`,
`/v8/projects/${encodeURIComponent(projectNameOrId)}`,
{ accountId }
);
return project;

View File

@@ -168,6 +168,17 @@ module.exports = async function prepare(session, binaryPath) {
'vercel.json': JSON.stringify({ version: 2 }),
'README.md': 'readme contents',
},
'deploy-default-with-sub-directory': {
'vercel.json': JSON.stringify({ version: 2 }),
'output/README.md':
'readme contents for deploy-default-with-sub-directory',
},
'deploy-default-with-conflicting-sub-directory': {
'list/vercel.json': JSON.stringify({ version: 2 }),
'list/list/README.md': 'nested nested readme contents',
'list/README.md':
'readme contents for deploy-default-with-conflicting-sub-directory',
},
'local-config-v2': {
[`main-${session}.html`]: '<h1>hello main</h1>',
[`test-${session}.html`]: '<h1>hello test</h1>',
@@ -192,10 +203,6 @@ module.exports = async function prepare(session, binaryPath) {
name: 'nested-level',
}),
},
'subdirectory-secret': {
'index.html': 'Home page',
'secret/file.txt': 'my secret',
},
'build-secret': {
'package.json': JSON.stringify({
private: true,

View File

@@ -125,6 +125,19 @@ ${stdout}
`;
}
async function vcLink(t, projectPath) {
const { exitCode, stderr, stdout } = await execa(
binaryPath,
['link', '--confirm', ...defaultArgs],
{
reject: false,
cwd: projectPath,
}
);
t.is(exitCode, 0, formatOutput({ stderr, stdout }));
}
// AVA's `t.context` can only be set before the tests,
// but we want to set it within as well
const context = {};
@@ -355,6 +368,8 @@ test('default command should prompt login with empty auth.json', async t => {
}
});
// NOTE: Test order is important here.
// This test MUST run before the tests below for them to work.
test('login', async t => {
t.timeout(ms('1m'));
@@ -378,6 +393,110 @@ test('login', async t => {
t.is(auth.token, token);
});
test('default command should deploy directory', async t => {
const projectDir = fixture('deploy-default-with-sub-directory');
const target = 'output';
await vcLink(t, path.join(projectDir, target));
const { exitCode, stderr, stdout } = await execa(
binaryPath,
[
// omit the default "deploy" command
target,
...defaultArgs,
],
{
cwd: projectDir,
}
);
t.is(exitCode, 0, formatOutput({ stdout, stderr }));
t.regex(stdout, /https:\/\/output-.+\.vercel\.app/);
});
test('default command should warn when deploying with conflicting subdirectory', async t => {
const projectDir = fixture('deploy-default-with-conflicting-sub-directory');
const target = 'list'; // command that conflicts with a sub directory
await vcLink(t, projectDir);
const { exitCode, stderr, stdout } = await execa(
binaryPath,
[
// omit the default "deploy" command
target,
...defaultArgs,
],
{
cwd: projectDir,
}
);
t.is(exitCode, 0, formatOutput({ stdout, stderr }));
t.regex(
stderr || '',
/Did you mean to deploy the subdirectory "list"\? Use `vc --cwd list` instead./
);
const listHeader = /project +latest deployment +state +age +username/;
t.regex(stdout || '', listHeader); // ensure `list` command still ran
});
test('deploy command should not warn when deploying with conflicting subdirectory and using --cwd', async t => {
const projectDir = fixture('deploy-default-with-conflicting-sub-directory');
const target = 'list'; // command that conflicts with a sub directory
await vcLink(t, path.join(projectDir, target));
const { exitCode, stderr, stdout } = await execa(
binaryPath,
['list', '--cwd', target, ...defaultArgs],
{
cwd: projectDir,
}
);
t.is(exitCode, 0, formatOutput({ stdout, stderr }));
t.notRegex(
stderr || '',
/Did you mean to deploy the subdirectory "list"\? Use `vc --cwd list` instead./
);
const listHeader = /project +latest deployment +state +age +username/;
t.regex(stdout || '', listHeader); // ensure `list` command still ran
});
test('default command should work with --cwd option', async t => {
const projectDir = fixture('deploy-default-with-conflicting-sub-directory');
const target = 'list'; // command that conflicts with a sub directory
await vcLink(t, path.join(projectDir, 'list'));
const { exitCode, stderr, stdout } = await execa(
binaryPath,
[
// omit the default "deploy" command
'--cwd',
target,
...defaultArgs,
],
{
cwd: projectDir,
}
);
t.is(exitCode, 0, formatOutput({ stderr, stdout }));
const url = stdout;
const deploymentResult = await fetch(`${url}/README.md`);
const body = await deploymentResult.text();
t.deepEqual(
body,
'readme contents for deploy-default-with-conflicting-sub-directory'
);
});
test('deploy using only now.json with `redirects` defined', async t => {
const target = fixture('redirects-v2');
@@ -1088,30 +1207,6 @@ test('output the version', async t => {
t.is(version, pkg.version);
});
test('should error with suggestion for secrets subcommand', async t => {
const target = fixture('subdirectory-secret');
const { exitCode, stderr, stdout } = await execa(
binaryPath,
['secret', 'add', 'key', 'value', ...defaultArgs],
{
cwd: target,
reject: false,
}
);
console.log(stderr);
console.log(stdout);
console.log(exitCode);
t.is(exitCode, 1);
t.regex(
stderr,
/secrets/gm,
`Expected "secrets" suggestion but received "${stderr}"`
);
});
test('should add secret with hyphen prefix', async t => {
const target = fixture('build-secret');
const key = 'mysecret';

View File

@@ -77,13 +77,13 @@ export const defaultProject = {
};
export function useProject(project = defaultProject) {
client.scenario.get(`/projects/${project.name}`, (_req, res) => {
client.scenario.get(`/v8/projects/${project.name}`, (_req, res) => {
res.json(project);
});
client.scenario.get(`/projects/${project.id}`, (_req, res) => {
client.scenario.get(`/v8/projects/${project.id}`, (_req, res) => {
res.json(project);
});
client.scenario.get(`/v7/projects/${project.id}/env`, (_req, res) => {
client.scenario.get(`/v8/projects/${project.id}/env`, (_req, res) => {
res.json({ envs });
});