Compare commits

..

24 Commits

Author SHA1 Message Date
Matthew Stanciu
a0479338c1 , 2022-07-12 12:58:44 -07:00
Matthew Stanciu
37a8037128 Add 2022-07-12 11:55:25 -07:00
Matthew Stanciu
65fd5c49f9 Optionally pass in state to mock deployment 2022-07-08 16:36:54 -07:00
Matthew Stanciu
75a19a1b8f Remove console.logs 2022-07-08 16:20:09 -07:00
Matthew Stanciu
d2456a21a1 Merge branch 'main' into update/fix-ls-visual-bugs 2022-07-08 13:03:39 -07:00
Matthew Stanciu
628071f659 [cli] Debug log error messages in create-git-meta (#8112)
This is a follow-up to #8094 which debug logs errors.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 19:30:28 +00:00
Matthew Stanciu
8f02a5d0b1 Fix unit tests 2022-07-08 12:03:14 -07:00
Matthew Stanciu
f4cb7b3b8b Merge branch 'main' into update/fix-ls-visual-bugs 2022-07-08 11:57:56 -07:00
Matthew Stanciu
5a7461dfe3 [cli] Explicitly use vc project vs. vc projects (#8113)
This is a follow-up to #8091 which:

- Makes `vc project` the default command, with `vc projects` aliased to `vc project` (previously it was not clear in the code which one was the "real" command)
- Makes some helper names for `ls` more specific

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 18:56:35 +00:00
Matthew Stanciu
88c309ec40 use title module for state string 2022-07-08 10:46:42 -07:00
Matthew Stanciu
e383068427 styfle suggestions 2022-07-08 10:39:14 -07:00
Sean Massa
599f8f675c [tests] remove commented code (#8109)
Removes a leftover commented line.
2022-07-08 16:52:55 +00:00
Sean Massa
d660f110a3 Merge branch 'main' into update/fix-ls-visual-bugs 2022-07-08 11:39:39 -05:00
Steven
0a8bc494fc [tests] Try building with ENABLE_VC_BUILD (#8110)
This will dogfood `vc build` which happens to improve this deployment time from 4 min down to 2 min.
2022-07-08 12:30:03 -04:00
Lee Robinson
34e008f42e [cli] Remove beta warning for vercel build (#7991)
This removes the `(beta)` output from `vercel build` as we move towards stability.

![image](https://user-images.githubusercontent.com/9113740/174356399-65f3d6bb-a241-49c8-9edb-167b25d6fa44.png)
2022-07-08 12:12:22 +00:00
Matthew Stanciu
9fada88f99 Fix unit tests 2022-07-07 21:31:03 -07:00
Matthew Stanciu
0d9082dcb8 Merge branch 'main' into update/fix-ls-visual-bugs 2022-07-07 21:13:32 -07:00
Matthew Stanciu
037633b3f1 [cli] Refactor vc project (#8091)
Since the `vc project` command is about to be expanded with 2 new subcommands, I think it makes sense to do a little bit of refactoring. The current `vc project` command is all in one file, with the logic for subcommands nested within if statements. I think the structure of `vc project` should look more like `vc env`, which is consistent with how commands with subcommands look throughout the rest of the codebase.

This PR moves the logic for the `project` subcommands into their own files.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 01:34:13 +00:00
Matthew Stanciu
1a6f3c0270 [cli] Fix vc deploy erroring when .git is corrupt (#8094)
### Related Issues

- Fixes https://github.com/vercel/customer-issues/issues/597

`vc deploy` currently fails with a confusing error if the user has a corrupt `.git` directory. This is caused by `create-git-meta` improperly handling the scenario where it detects a `.git` directory but cannot find git information. This PR handles this error.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-08 01:07:43 +00:00
Matthew Stanciu
4da688cff3 Merge branch 'update/fix-ls-visual-bugs' of https://github.com/vercel/vercel into update/fix-ls-visual-bugs 2022-07-07 17:04:07 -07:00
Matthew Stanciu
af40cf43ae Fix tests 2022-07-07 17:03:41 -07:00
Matthew Stanciu
4c100a191c Merge branch 'main' into update/fix-ls-visual-bugs 2022-07-07 16:43:15 -07:00
Matthew Stanciu
a637f2f949 Remove project ls message 2022-07-07 16:43:02 -07:00
Matthew Stanciu
f3683fff3f Fix ls visual bugs 2022-07-07 16:37:14 -07:00
23 changed files with 681 additions and 417 deletions

View File

@@ -5,7 +5,8 @@
"description": "API for the vercel/vercel repo",
"main": "index.js",
"scripts": {
"vercel-build": "node ../utils/run.js build all"
"//TODO": "We should add this pkg to yarn workspaces",
"vercel-build": "cd .. && yarn install && yarn vercel-build"
},
"dependencies": {
"@sentry/node": "5.11.1",

View File

@@ -25,8 +25,8 @@ export default new Map([
['logout', 'logout'],
['logs', 'logs'],
['ls', 'list'],
['project', 'projects'],
['projects', 'projects'],
['project', 'project'],
['projects', 'project'],
['pull', 'pull'],
['remove', 'remove'],
['rm', 'remove'],

View File

@@ -1,10 +1,10 @@
import chalk from 'chalk';
import ms from 'ms';
import table from 'text-table';
import title from 'title';
import Now from '../util';
import getArgs from '../util/get-args';
import { handleError } from '../util/error';
import cmd from '../util/output/cmd';
import logo from '../util/output/logo';
import elapsed from '../util/output/elapsed';
import strlen from '../util/strlen';
@@ -42,6 +42,8 @@ const help = () => {
-m, --meta Filter deployments by metadata (e.g.: ${chalk.dim(
'`-m KEY=value`'
)}). Can appear many times.
-i, --inspect Display dashboard inspect URLs
--prod Filter for production URLs
-N, --next Show next page of results
${chalk.dim('Examples:')}
@@ -77,6 +79,9 @@ export default async function main(client: Client) {
'-m': '--meta',
'--next': Number,
'-N': '--next',
'--inspect': Boolean,
'-i': '--inspect',
'--prod': Boolean,
'--confirm': Boolean,
});
} catch (err) {
@@ -99,9 +104,10 @@ export default async function main(client: Client) {
}
const yes = argv['--confirm'] || false;
const inspect = argv['--inspect'] || false;
const prod = argv['--prod'] || false;
const meta = parseMeta(argv['--meta']);
const { includeScheme } = config;
let paths = [process.cwd()];
const pathValidation = await validatePaths(client, paths);
@@ -139,6 +145,7 @@ export default async function main(client: Client) {
link.project = linkedProject.project;
}
const isTeam = org?.type === 'team';
let { contextName, team } = await getScope(client);
// If user passed in a custom scope, update the current team & context name
@@ -255,37 +262,51 @@ export default async function main(client: Client) {
}
log(
`Deployments under ${chalk.bold(contextName)} ${elapsed(
`${prod ? `Production deployments` : `Deployments`} for ${chalk.bold(
chalk.magenta(app)
)} under ${chalk.bold(chalk.magenta(contextName))} ${elapsed(
Date.now() - start
)}`
);
// information to help the user find other deployments or instances
if (app == null) {
log(
`To list more deployments for a project run ${cmd(
`${getCommandName('ls [project]')}`
)}`
);
}
log(
`To list more deployments for a project, run ${getCommandName(
'ls [project]'
)}.`
);
print('\n');
client.output.print(
`${table(
[
['project', 'latest deployment', 'state', 'age', 'username'].map(
header => chalk.dim(header)
),
(isTeam
? [
'age',
inspect ? 'inspect url' : 'deployment url',
'state',
'duration',
'username',
]
: [
'age',
inspect ? 'inspect url' : 'deployment url',
'state',
'duration',
]
).map(header => chalk.bold(chalk.cyan(header))),
...deployments
.sort(sortRecent())
.map(dep => [
.map((dep, i) => [
[
getProjectName(dep),
chalk.bold((includeScheme ? 'https://' : '') + dep.url),
stateString(dep.state),
chalk.gray(ms(Date.now() - dep.createdAt)),
dep.creator.username,
i === 0
? chalk.bold(`${getDeployUrl(dep, inspect)}`)
: `${getDeployUrl(dep, inspect)}`,
stateString(dep.state),
chalk.gray(getDeploymentDuration(dep)),
isTeam ? chalk.gray(dep.creator.username) : '',
],
])
// flatten since the previous step returns a nested
@@ -298,8 +319,8 @@ export default async function main(client: Client) {
),
],
{
align: ['l', 'l', 'r', 'l', 'l'],
hsep: ' '.repeat(4),
align: isTeam ? ['l', 'l', 'l', 'l', 'l'] : ['l', 'l', 'l', 'l'],
hsep: ' '.repeat(isTeam ? 4 : 5),
stringLength: strlen,
}
).replace(/^/gm, ' ')}\n\n`
@@ -315,27 +336,43 @@ export default async function main(client: Client) {
}
}
function getProjectName(d: Deployment) {
// We group both file and files into a single project
if (d.name === 'file') {
return 'files';
}
function getDeployUrl(
deployment: Deployment,
inspect: boolean | undefined
): string {
return inspect ? deployment.inspectorUrl : 'https://' + deployment.url;
}
return d.name;
export function getDeploymentDuration(dep: Deployment): string {
if (!dep || !dep.ready || !dep.buildingAt) {
return '?';
}
const duration = ms(dep.ready - dep.buildingAt);
if (duration === '0ms') {
return '--';
}
return duration;
}
// renders the state string
export function stateString(s: string) {
const CIRCLE = '● ';
// make `s` title case
const sTitle = title(s);
switch (s) {
case 'INITIALIZING':
return chalk.yellow(s);
case 'BUILDING':
case 'DEPLOYING':
case 'ANALYZING':
return chalk.yellow(CIRCLE) + sTitle;
case 'ERROR':
return chalk.red(s);
return chalk.red(CIRCLE) + sTitle;
case 'READY':
return s;
return chalk.green(CIRCLE) + sTitle;
case 'QUEUED':
return chalk.white(CIRCLE) + sTitle;
case 'CANCELED':
return chalk.gray(sTitle);
default:
return chalk.gray('UNKNOWN');
}

View File

@@ -0,0 +1,55 @@
import chalk from 'chalk';
import ms from 'ms';
import Client from '../../util/client';
import { getCommandName } from '../../util/pkg-name';
export default async function add(
client: Client,
args: string[],
contextName: string
) {
const { output } = client;
if (args.length !== 1) {
output.error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('project add <name>')}`
)}`
);
if (args.length > 1) {
const example = chalk.cyan(
`${getCommandName(`project add "${args.join(' ')}"`)}`
);
output.log(
`If your project name has spaces, make sure to wrap it in quotes. Example: \n ${example} `
);
}
return 1;
}
const start = Date.now();
const [name] = args;
try {
await client.fetch('/projects', {
method: 'POST',
body: { name },
});
} catch (error) {
if (error.status === 409) {
// project already exists, so we can
// show a success message
} else {
throw error;
}
}
const elapsed = ms(Date.now() - start);
output.log(
`${chalk.cyan('Success!')} Project ${chalk.bold(
name.toLowerCase()
)} added (${chalk.bold(contextName)}) ${chalk.gray(`[${elapsed}]`)}`
);
return;
}

View File

@@ -0,0 +1,111 @@
import chalk from 'chalk';
import Client from '../../util/client';
import getArgs from '../../util/get-args';
import getInvalidSubcommand from '../../util/get-invalid-subcommand';
import getScope from '../../util/get-scope';
import handleError from '../../util/handle-error';
import logo from '../../util/output/logo';
import { getPkgName } from '../../util/pkg-name';
import validatePaths from '../../util/validate-paths';
import add from './add';
import list from './list';
import rm from './rm';
const help = () => {
console.log(`
${chalk.bold(`${logo} ${getPkgName()} project`)} [options] <command>
${chalk.dim('Commands:')}
ls Show all projects in the selected team/user
add [name] Add a new project
rm [name] Remove a project
${chalk.dim('Options:')}
-h, --help Output usage information
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
'TOKEN'
)} Login token
-S, --scope Set a custom scope
-N, --next Show next page of results
${chalk.dim('Examples:')}
${chalk.gray('')} Add a new project
${chalk.cyan(`$ ${getPkgName()} project add my-project`)}
${chalk.gray('')} Paginate projects, where ${chalk.dim(
'`1584722256178`'
)} is the time in milliseconds since the UNIX epoch.
${chalk.cyan(`$ ${getPkgName()} project ls --next 1584722256178`)}
`);
};
const COMMAND_CONFIG = {
ls: ['ls', 'list'],
add: ['add'],
rm: ['rm', 'remove'],
connect: ['connect'],
};
export default async function main(client: Client) {
let argv: any;
let subcommand: string | string[];
try {
argv = getArgs(client.argv.slice(2), {
'--next': Number,
'-N': '--next',
'--yes': Boolean,
});
} catch (error) {
handleError(error);
return 1;
}
if (argv['--help']) {
help();
return 2;
}
argv._ = argv._.slice(1);
subcommand = argv._[0] || 'list';
const args = argv._.slice(1);
const { output } = client;
let paths = [process.cwd()];
const pathValidation = await validatePaths(client, paths);
if (!pathValidation.valid) {
return pathValidation.exitCode;
}
let contextName = '';
try {
({ contextName } = await getScope(client));
} catch (error) {
if (error.code === 'NOT_AUTHORIZED' || error.code === 'TEAM_DELETED') {
output.error(error.message);
return 1;
}
throw error;
}
switch (subcommand) {
case 'ls':
case 'list':
return await list(client, argv, args, contextName);
case 'add':
return await add(client, args, contextName);
case 'rm':
case 'remove':
return await rm(client, args);
default:
output.error(getInvalidSubcommand(COMMAND_CONFIG));
help();
return 2;
}
}

View File

@@ -0,0 +1,86 @@
import chalk from 'chalk';
import ms from 'ms';
import table from 'text-table';
import Client from '../../util/client';
import getCommandFlags from '../../util/get-command-flags';
import { getCommandName } from '../../util/pkg-name';
import strlen from '../../util/strlen';
export default async function list(
client: Client,
argv: any,
args: string[],
contextName: string
) {
const { output } = client;
if (args.length !== 0) {
output.error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('project ls')}`
)}`
);
return 2;
}
const start = Date.now();
output.spinner(`Fetching projects in ${chalk.bold(contextName)}`);
let projectsUrl = '/v4/projects/?limit=20';
const next = argv['--next'] || false;
if (next) {
projectsUrl += `&until=${next}`;
}
const {
projects: list,
pagination,
}: {
projects: [{ name: string; updatedAt: number }];
pagination: { count: number; next: number };
} = await client.fetch(projectsUrl, {
method: 'GET',
});
output.stopSpinner();
const elapsed = ms(Date.now() - start);
output.log(
`${list.length > 0 ? 'Projects' : 'No projects'} found under ${chalk.bold(
contextName
)} ${chalk.gray(`[${elapsed}]`)}`
);
if (list.length > 0) {
const cur = Date.now();
const header = [['', 'name', 'updated'].map(title => chalk.dim(title))];
const out = table(
header.concat(
list.map(secret => [
'',
chalk.bold(secret.name),
chalk.gray(`${ms(cur - secret.updatedAt)} ago`),
])
),
{
align: ['l', 'l', 'l'],
hsep: ' '.repeat(2),
stringLength: strlen,
}
);
if (out) {
output.print(`\n${out}\n\n`);
}
if (pagination && pagination.count === 20) {
const flags = getCommandFlags(argv, ['_', '--next', '-N', '-d', '-y']);
const nextCmd = `project ls${flags} --next ${pagination.next}`;
output.log(`To display the next page run ${getCommandName(nextCmd)}`);
}
}
return 0;
}

View File

@@ -0,0 +1,63 @@
import chalk from 'chalk';
import ms from 'ms';
import Client from '../../util/client';
import { emoji, prependEmoji } from '../../util/emoji';
import confirm from '../../util/input/confirm';
import { getCommandName } from '../../util/pkg-name';
const e = encodeURIComponent;
export default async function rm(client: Client, args: string[]) {
if (args.length !== 1) {
client.output.error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('project rm <name>')}`
)}`
);
return 1;
}
const name = args[0];
const start = Date.now();
const yes = await readConfirmation(client, name);
if (!yes) {
client.output.log('User abort');
return 0;
}
try {
await client.fetch(`/v2/projects/${e(name)}`, {
method: 'DELETE',
});
} catch (err) {
if (err.status === 404) {
client.output.error('No such project exists');
return 1;
}
}
const elapsed = ms(Date.now() - start);
client.output.log(
`${chalk.cyan('Success!')} Project ${chalk.bold(name)} removed ${chalk.gray(
`[${elapsed}]`
)}`
);
return 0;
}
async function readConfirmation(
client: Client,
projectName: string
): Promise<boolean> {
client.output.print(
prependEmoji(
`The project ${chalk.bold(projectName)} will be removed permanently.\n` +
`It will also delete everything under the project including deployments.\n`,
emoji('warning')
)
);
return await confirm(client, `${chalk.bold.red('Are you sure?')}`, false);
}

View File

@@ -1,302 +0,0 @@
import chalk from 'chalk';
import ms from 'ms';
import table from 'text-table';
import strlen from '../util/strlen';
import getArgs from '../util/get-args';
import { handleError, error } from '../util/error';
import exit from '../util/exit';
import logo from '../util/output/logo';
import getScope from '../util/get-scope';
import getCommandFlags from '../util/get-command-flags';
import { getPkgName, getCommandName } from '../util/pkg-name';
import Client from '../util/client';
const e = encodeURIComponent;
const help = () => {
console.log(`
${chalk.bold(`${logo} ${getPkgName()} projects`)} [options] <command>
${chalk.dim('Commands:')}
ls Show all projects in the selected team/user
add [name] Add a new project
rm [name] Remove a project
${chalk.dim('Options:')}
-h, --help Output usage information
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
'TOKEN'
)} Login token
-S, --scope Set a custom scope
-N, --next Show next page of results
${chalk.dim('Examples:')}
${chalk.gray('')} Add a new project
${chalk.cyan(`$ ${getPkgName()} projects add my-project`)}
${chalk.gray('')} Paginate projects, where ${chalk.dim(
'`1584722256178`'
)} is the time in milliseconds since the UNIX epoch.
${chalk.cyan(`$ ${getPkgName()} projects ls --next 1584722256178`)}
`);
};
let argv: any;
let subcommand: string | string[];
const main = async (client: Client) => {
try {
argv = getArgs(client.argv.slice(2), {
'--next': Number,
'-N': '--next',
});
} catch (error) {
handleError(error);
return exit(1);
}
argv._ = argv._.slice(1);
subcommand = argv._[0] || 'list';
if (argv['--help']) {
help();
return exit(2);
}
const { output } = client;
let contextName = null;
try {
({ contextName } = await getScope(client));
} catch (err) {
if (err.code === 'NOT_AUTHORIZED' || err.code === 'TEAM_DELETED') {
output.error(err.message);
return 1;
}
throw err;
}
try {
await run({ client, contextName });
} catch (err) {
handleError(err);
exit(1);
}
};
export default async (client: Client) => {
try {
await main(client);
} catch (err) {
handleError(err);
process.exit(1);
}
};
async function run({
client,
contextName,
}: {
client: Client;
contextName: string;
}) {
const { output } = client;
const args = argv._.slice(1);
const start = Date.now();
if (subcommand === 'ls' || subcommand === 'list') {
if (args.length !== 0) {
console.error(
error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('projects ls')}`
)}`
)
);
return exit(2);
}
output.spinner(`Fetching projects in ${chalk.bold(contextName)}`);
let projectsUrl = '/v4/projects/?limit=20';
const next = argv['--next'];
if (next) {
projectsUrl += `&until=${next}`;
}
const {
projects: list,
pagination,
}: {
projects: [{ name: string; updatedAt: number }];
pagination: { count: number; next: number };
} = await client.fetch(projectsUrl, {
method: 'GET',
});
output.stopSpinner();
const elapsed = ms(Date.now() - start);
console.log(
`> ${
list.length > 0 ? 'Projects' : 'No projects'
} found under ${chalk.bold(contextName)} ${chalk.gray(`[${elapsed}]`)}`
);
if (list.length > 0) {
const cur = Date.now();
const header = [['', 'name', 'updated'].map(title => chalk.dim(title))];
const out = table(
header.concat(
list.map(secret => [
'',
chalk.bold(secret.name),
chalk.gray(`${ms(cur - secret.updatedAt)} ago`),
])
),
{
align: ['l', 'l', 'l'],
hsep: ' '.repeat(2),
stringLength: strlen,
}
);
if (out) {
console.log(`\n${out}\n`);
}
if (pagination && pagination.count === 20) {
const flags = getCommandFlags(argv, ['_', '--next', '-N', '-d', '-y']);
const nextCmd = `projects ls${flags} --next ${pagination.next}`;
console.log(`To display the next page run ${getCommandName(nextCmd)}`);
}
}
return;
}
if (subcommand === 'rm' || subcommand === 'remove') {
if (args.length !== 1) {
console.error(
error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('project rm <name>')}`
)}`
)
);
return exit(1);
}
const name = args[0];
const yes = await readConfirmation(name);
if (!yes) {
console.error(error('User abort'));
return exit(0);
}
try {
await client.fetch(`/v2/projects/${e(name)}`, {
method: 'DELETE',
});
} catch (err) {
if (err.status === 404) {
console.error(error('No such project exists'));
return exit(1);
}
}
const elapsed = ms(Date.now() - start);
console.log(
`${chalk.cyan('> Success!')} Project ${chalk.bold(
name
)} removed ${chalk.gray(`[${elapsed}]`)}`
);
return;
}
if (subcommand === 'add') {
if (args.length !== 1) {
console.error(
error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('projects add <name>')}`
)}`
)
);
if (args.length > 1) {
const example = chalk.cyan(
`${getCommandName(`projects add "${args.join(' ')}"`)}`
);
console.log(
`> If your project name has spaces, make sure to wrap it in quotes. Example: \n ${example} `
);
}
return exit(1);
}
const [name] = args;
try {
await client.fetch('/projects', {
method: 'POST',
body: { name },
});
} catch (error) {
if (error.status === 409) {
// project already exists, so we can
// show a success message
} else {
throw error;
}
}
const elapsed = ms(Date.now() - start);
console.log(
`${chalk.cyan('> Success!')} Project ${chalk.bold(
name.toLowerCase()
)} added (${chalk.bold(contextName)}) ${chalk.gray(`[${elapsed}]`)}`
);
return;
}
console.error(error('Please specify a valid subcommand: ls | add | rm'));
help();
exit(2);
}
process.on('uncaughtException', err => {
handleError(err);
exit(1);
});
function readConfirmation(projectName: string) {
return new Promise(resolve => {
process.stdout.write(
`The project: ${chalk.bold(projectName)} will be removed permanently.\n` +
`It will also delete everything under the project including deployments.\n`
);
process.stdout.write(
`${chalk.bold.red('> Are you sure?')} ${chalk.gray('[y/N] ')}`
);
process.stdin
.on('data', d => {
process.stdin.pause();
resolve(d.toString().trim().toLowerCase() === 'y');
})
.resume();
});
}

View File

@@ -172,7 +172,8 @@ const main = async () => {
// * a subcommand (as in: `vercel ls`)
const targetOrSubcommand = argv._[2];
const betaCommands: string[] = ['build'];
// Currently no beta commands - add here as needed
const betaCommands: string[] = [''];
if (betaCommands.includes(targetOrSubcommand)) {
console.log(
`${chalk.grey(
@@ -652,8 +653,8 @@ const main = async () => {
case 'logout':
func = require('./commands/logout').default;
break;
case 'projects':
func = require('./commands/projects').default;
case 'project':
func = require('./commands/project').default;
break;
case 'pull':
func = require('./commands/pull').default;

View File

@@ -28,7 +28,6 @@ export interface AuthConfig {
export interface GlobalConfig {
_?: string;
currentTeam?: string;
includeScheme?: string;
collectMetrics?: boolean;
api?: string;

View File

@@ -6,16 +6,16 @@ import { exec } from 'child_process';
import { GitMetadata } from '../../types';
import { Output } from '../output';
export function isDirty(directory: string): Promise<boolean> {
return new Promise((resolve, reject) => {
export function isDirty(directory: string, output: Output): Promise<boolean> {
return new Promise(resolve => {
exec('git status -s', { cwd: directory }, function (err, stdout, stderr) {
if (err) return reject(err);
if (stderr)
return reject(
new Error(
`Failed to determine if git repo has been modified: ${stderr.trim()}`
)
);
let debugMessage = `Failed to determine if Git repo has been modified:`;
if (err || stderr) {
if (err) debugMessage += `\n${err}`;
if (stderr) debugMessage += `\n${stderr.trim()}`;
output.debug(debugMessage);
return resolve(false);
}
resolve(stdout.trim().length > 0);
});
});
@@ -64,10 +64,19 @@ export async function createGitMeta(
return;
}
const [commit, dirty] = await Promise.all([
getLastCommit(directory),
isDirty(directory),
getLastCommit(directory).catch(err => {
output.debug(
`Failed to get last commit. The directory is likely not a Git repo, there are no latest commits, or it is corrupted.\n${err}`
);
return;
}),
isDirty(directory, output),
]);
if (!commit) {
return;
}
return {
remoteUrl,
commitAuthorName: commit.author.name,

View File

@@ -0,0 +1,11 @@
[core]
repositoryformatversion = 0
fileMode = false
bare = false
logallrefupdates = true
[user]
name = TechBug2012
email = <>
[remote "origin"]
url = https://github.com/MatthewStanciu/git-test
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,19 @@
export function pluckIdentifiersFromDeploymentList(output: string): {
project: string | undefined;
org: string | undefined;
} {
const project = output.match(/(?<=Deployments for )(.*)(?= under)/);
const org = output.match(/(?<=under )(.*)(?= \[)/);
return {
project: project?.[0],
org: org?.[0],
};
}
export function parseSpacedTableRow(output: string): string[] {
return output
.trim()
.replace(/ {1} +/g, ',')
.split(',');
}

View File

@@ -0,0 +1,23 @@
import { MockClient } from '../mocks/client';
export function readOutputStream(
client: MockClient,
length: number = 3
): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const timeout = setTimeout(() => {
reject();
}, 3000);
client.stderr.resume();
client.stderr.on('data', chunk => {
chunks.push(chunk);
if (chunks.length === length) {
clearTimeout(timeout);
resolve(chunks.toString().replace(/,/g, ''));
}
});
client.stderr.on('error', reject);
});
}

View File

@@ -1323,12 +1323,7 @@ test('[vc projects] should create a project successfully', async t => {
Math.random().toString(36).split('.')[1]
}`;
const vc = execa(binaryPath, [
'projects',
'add',
projectName,
...defaultArgs,
]);
const vc = execa(binaryPath, ['project', 'add', projectName, ...defaultArgs]);
await waitForPrompt(vc, chunk =>
chunk.includes(`Success! Project ${projectName} added`)
@@ -1339,7 +1334,7 @@ test('[vc projects] should create a project successfully', async t => {
// creating the same project again should succeed
const vc2 = execa(binaryPath, [
'projects',
'project',
'add',
projectName,
...defaultArgs,
@@ -3517,7 +3512,7 @@ test('`vc --debug project ls` should output the projects listing', async t => {
t.is(exitCode, 0, formatOutput({ stderr, stdout }));
t.true(
stdout.includes('> Projects found under'),
stderr.includes('> Projects found under'),
formatOutput({ stderr, stdout })
);
});

View File

@@ -7,17 +7,22 @@ import { Build, User } from '../../src/types';
let deployments = new Map<string, Deployment>();
let deploymentBuilds = new Map<Deployment, Build[]>();
type State = Deployment['readyState'];
export function useDeployment({
creator,
state = 'READY',
}: {
creator: Pick<User, 'id' | 'email' | 'name'>;
state?: State;
}) {
const createdAt = Date.now();
const url = new URL(chance().url());
const deployment: Deployment = {
id: `dpl_${chance().guid()}`,
url: url.hostname,
name: '',
name: chance.name,
meta: {},
regions: [],
routes: [],
@@ -26,13 +31,15 @@ export function useDeployment({
version: 2,
createdAt,
createdIn: 'sfo1',
buildingAt: Date.now(),
ownerId: creator.id,
creator: {
uid: creator.id,
email: creator.email,
username: creator.name,
},
readyState: 'READY',
readyState: state,
state: state,
env: {},
build: { env: {} },
target: 'production',

View File

@@ -25,8 +25,6 @@ type GetMatcherType<TP, TResult> = TP extends PromiseFunction
? (...args: Tail<Parameters<TP>>) => TResult
: TP;
//type T = GetMatcherType<typeof matchers['toOutput'], void>;
type GetMatchersType<TMatchers, TResult> = {
[P in keyof TMatchers]: GetMatcherType<TMatchers[P], TResult>;
};

View File

@@ -157,6 +157,64 @@ export function useProject(project: Partial<Project> = defaultProject) {
res.json({ envs });
});
client.scenario.post(`/v4/projects/${project.id}/link`, (req, res) => {
const { type, repo, org } = req.body;
if (
(type === 'github' || type === 'gitlab' || type === 'bitbucket') &&
(repo === 'user/repo' || repo === 'user2/repo2')
) {
project.link = {
type,
repo,
repoId: 1010,
org,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
res.json(project);
} else {
if (type === 'github') {
res.status(400).json({
message: `To link a GitHub repository, you need to install the GitHub integration first. (400)\nInstall GitHub App: https://github.com/apps/vercel`,
meta: {
action: 'Install GitHub App',
link: 'https://github.com/apps/vercel',
repo,
},
});
} else {
res.status(400).json({
code: 'repo_not_found',
message: `The repository "${repo}" couldn't be found in your linked ${formatProvider(
type
)} account.`,
});
}
}
});
client.scenario.delete(`/v4/projects/${project.id}/link`, (_req, res) => {
if (project.link) {
project.link = undefined;
}
res.json(project);
});
client.scenario.get(`/v4/projects`, (req, res) => {
res.json({
projects: [defaultProject],
pagination: null,
});
});
client.scenario.post(`/projects`, (req, res) => {
const { name } = req.body;
if (name === project.name) {
res.json(project);
}
});
client.scenario.delete(`/:version/projects/${project.id}`, (_req, res) => {
res.json({});
});
return { project, envs };
}

View File

@@ -1,10 +1,15 @@
import { client, MockClient } from '../../mocks/client';
import { client } from '../../mocks/client';
import { useUser } from '../../mocks/user';
import list, { stateString } from '../../../src/commands/list';
import { join } from 'path';
import { useTeams } from '../../mocks/team';
import { defaultProject, useProject } from '../../mocks/project';
import { useDeployment } from '../../mocks/deployment';
import { readOutputStream } from '../../helpers/read-output-stream';
import {
parseSpacedTableRow,
pluckIdentifiersFromDeploymentList,
} from '../../helpers/parse-table';
const fixture = (name: string) =>
join(__dirname, '../../fixtures/unit/commands/list', name);
@@ -30,11 +35,12 @@ describe('list', () => {
await list(client);
const output = await readOutputStream(client);
const output = await readOutputStream(client, 4);
const { org } = getDataFromIntro(output.split('\n')[0]);
const header: string[] = parseTable(output.split('\n')[2]);
const data: string[] = parseTable(output.split('\n')[3]);
const { org } = pluckIdentifiersFromDeploymentList(output.split('\n')[0]);
const header: string[] = parseSpacedTableRow(output.split('\n')[3]);
const data: string[] = parseSpacedTableRow(output.split('\n')[4]);
data.shift();
data.splice(2, 1);
expect(org).toEqual(team[0].slug);
@@ -47,7 +53,7 @@ describe('list', () => {
]);
expect(data).toEqual([
deployment.url,
`https://${deployment.url}`,
stateString(deployment.state || ''),
user.name,
]);
@@ -72,11 +78,12 @@ describe('list', () => {
client.setArgv(deployment.name);
await list(client);
const output = await readOutputStream(client);
const output = await readOutputStream(client, 4);
const { org } = getDataFromIntro(output.split('\n')[0]);
const header: string[] = parseTable(output.split('\n')[2]);
const data: string[] = parseTable(output.split('\n')[3]);
const { org } = pluckIdentifiersFromDeploymentList(output.split('\n')[0]);
const header: string[] = parseSpacedTableRow(output.split('\n')[3]);
const data: string[] = parseSpacedTableRow(output.split('\n')[4]);
data.shift();
data.splice(2, 1);
expect(org).toEqual(teamSlug);
@@ -89,7 +96,7 @@ describe('list', () => {
'username',
]);
expect(data).toEqual([
deployment.url,
`https://${deployment.url}`,
stateString(deployment.state || ''),
user.name,
]);
@@ -98,42 +105,3 @@ describe('list', () => {
}
});
});
function getDataFromIntro(output: string): {
project: string | undefined;
org: string | undefined;
} {
const project = output.match(/(?<=Deployments for )(.*)(?= under)/);
const org = output.match(/(?<=under )(.*)(?= \[)/);
return {
project: project?.[0],
org: org?.[0],
};
}
function parseTable(output: string): string[] {
return output
.trim()
.replace(/ {3} +/g, ',')
.split(',');
}
function readOutputStream(client: MockClient): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const timeout = setTimeout(() => {
reject();
}, 3000);
client.stderr.resume();
client.stderr.on('data', chunk => {
chunks.push(chunk);
if (chunks.length === 3) {
clearTimeout(timeout);
resolve(chunks.toString().replace(/,/g, ''));
}
});
client.stderr.on('error', reject);
});
}

View File

@@ -0,0 +1,97 @@
import projects from '../../../src/commands/project';
import { useUser } from '../../mocks/user';
import { useTeams } from '../../mocks/team';
import { defaultProject, useProject } from '../../mocks/project';
import { client } from '../../mocks/client';
import { Project } from '../../../src/types';
import { readOutputStream } from '../../helpers/read-output-stream';
import {
pluckIdentifiersFromDeploymentList,
parseSpacedTableRow,
} from '../../helpers/parse-table';
describe('project', () => {
describe('list', () => {
it('should list deployments under a user', async () => {
const user = useUser();
const project = useProject({
...defaultProject,
});
client.setArgv('project', 'ls');
await projects(client);
const output = await readOutputStream(client, 2);
const { org } = pluckIdentifiersFromDeploymentList(output.split('\n')[0]);
const header: string[] = parseSpacedTableRow(output.split('\n')[2]);
const data: string[] = parseSpacedTableRow(output.split('\n')[3]);
data.pop();
expect(org).toEqual(user.username);
expect(header).toEqual(['name', 'updated']);
expect(data).toEqual([project.project.name]);
});
it('should list deployments for a team', async () => {
useUser();
const team = useTeams('team_dummy');
const project = useProject({
...defaultProject,
});
client.config.currentTeam = team[0].id;
client.setArgv('project', 'ls');
await projects(client);
const output = await readOutputStream(client, 2);
const { org } = pluckIdentifiersFromDeploymentList(output.split('\n')[0]);
const header: string[] = parseSpacedTableRow(output.split('\n')[2]);
const data: string[] = parseSpacedTableRow(output.split('\n')[3]);
data.pop();
expect(org).toEqual(team[0].slug);
expect(header).toEqual(['name', 'updated']);
expect(data).toEqual([project.project.name]);
});
});
describe('add', () => {
it('should add a project', async () => {
const user = useUser();
useProject({
...defaultProject,
id: 'test-project',
name: 'test-project',
});
client.setArgv('project', 'add', 'test-project');
await projects(client);
const project: Project = await client.fetch(`/v8/projects/test-project`);
expect(project).toBeDefined();
expect(client.stderr).toOutput(
`Success! Project test-project added (${user.username})`
);
});
});
describe('rm', () => {
it('should remove a project', async () => {
useUser();
useProject({
...defaultProject,
id: 'test-project',
name: 'test-project',
});
client.setArgv('project', 'rm', 'test-project');
const projectsPromise = projects(client);
await expect(client.stderr).toOutput(
`The project test-project will be removed permanently.`
);
client.stdin.write('y\n');
const exitCode = await projectsPromise;
expect(exitCode).toEqual(0);
});
});
});

View File

@@ -1,5 +1,6 @@
import { join } from 'path';
import fs from 'fs-extra';
import os from 'os';
import { getWriteableDirectory } from '@vercel/build-utils';
import {
createGitMeta,
@@ -41,7 +42,7 @@ describe('createGitMeta', () => {
const directory = fixture('dirty');
try {
await fs.rename(join(directory, 'git'), join(directory, '.git'));
const dirty = await isDirty(directory);
const dirty = await isDirty(directory, client.output);
expect(dirty).toBeTruthy();
} finally {
await fs.rename(join(directory, '.git'), join(directory, 'git'));
@@ -51,7 +52,7 @@ describe('createGitMeta', () => {
const directory = fixture('not-dirty');
try {
await fs.rename(join(directory, 'git'), join(directory, '.git'));
const dirty = await isDirty(directory);
const dirty = await isDirty(directory, client.output);
expect(dirty).toBeFalsy();
} finally {
await fs.rename(join(directory, '.git'), join(directory, 'git'));
@@ -125,4 +126,27 @@ describe('createGitMeta', () => {
await fs.rename(join(directory, '.git'), join(directory, 'git'));
}
});
it('fails when `.git` is corrupt', async () => {
const directory = fixture('git-corrupt');
const tmpDir = join(os.tmpdir(), 'git-corrupt');
try {
// Copy the fixture into a temp dir so that we don't pick
// up Git information from the `vercel/vercel` repo itself
await fs.copy(directory, tmpDir);
await fs.rename(join(tmpDir, 'git'), join(tmpDir, '.git'));
client.output.debugEnabled = true;
const data = await createGitMeta(tmpDir, client.output);
await expect(client.stderr).toOutput(
`Failed to get last commit. The directory is likely not a Git repo, there are no latest commits, or it is corrupted.`
);
await expect(client.stderr).toOutput(
`Failed to determine if Git repo has been modified:`
);
expect(data).toBeUndefined();
} finally {
await fs.remove(tmpDir);
}
});
});

View File

@@ -53,6 +53,8 @@ export interface Deployment {
| 'BUILDING'
| 'DEPLOYING'
| 'READY'
| 'QUEUED'
| 'CANCELED'
| 'ERROR';
state?:
| 'INITIALIZING'
@@ -60,6 +62,8 @@ export interface Deployment {
| 'BUILDING'
| 'DEPLOYING'
| 'READY'
| 'QUEUED'
| 'CANCELED'
| 'ERROR';
createdAt: number;
createdIn: string;

View File

@@ -20,7 +20,7 @@
],
"build": {
"env": {
"ENABLE_FILE_SYSTEM_API": "1"
"ENABLE_VC_BUILD": "1"
}
},
"github": {