diff --git a/packages/cli/src/commands/project/add.ts b/packages/cli/src/commands/project/add.ts new file mode 100644 index 000000000..6f9605012 --- /dev/null +++ b/packages/cli/src/commands/project/add.ts @@ -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 ')}` + )}` + ); + + 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; +} diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts new file mode 100644 index 000000000..fd4ffcf73 --- /dev/null +++ b/packages/cli/src/commands/project/index.ts @@ -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] + + ${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; + } +} diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts new file mode 100644 index 000000000..2c9f8c6ae --- /dev/null +++ b/packages/cli/src/commands/project/list.ts @@ -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; +} diff --git a/packages/cli/src/commands/project/rm.ts b/packages/cli/src/commands/project/rm.ts new file mode 100644 index 000000000..6fb63d72a --- /dev/null +++ b/packages/cli/src/commands/project/rm.ts @@ -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 ')}` + )}` + ); + 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 { + 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); +} diff --git a/packages/cli/src/commands/projects.ts b/packages/cli/src/commands/projects.ts deleted file mode 100644 index d221131d2..000000000 --- a/packages/cli/src/commands/projects.ts +++ /dev/null @@ -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] - - ${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 ')}` - )}` - ) - ); - 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 ')}` - )}` - ) - ); - - 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(); - }); -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 007250ecb..2f57698de 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -653,7 +653,7 @@ const main = async () => { func = require('./commands/logout').default; break; case 'projects': - func = require('./commands/projects').default; + func = require('./commands/project').default; break; case 'pull': func = require('./commands/pull').default; diff --git a/packages/cli/test/helpers/parse-table.ts b/packages/cli/test/helpers/parse-table.ts new file mode 100644 index 000000000..008b15995 --- /dev/null +++ b/packages/cli/test/helpers/parse-table.ts @@ -0,0 +1,19 @@ +export 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], + }; +} + +export function parseTable(output: string): string[] { + return output + .trim() + .replace(/ {1} +/g, ',') + .split(','); +} diff --git a/packages/cli/test/helpers/read-output-stream.ts b/packages/cli/test/helpers/read-output-stream.ts new file mode 100644 index 000000000..5f4b76c80 --- /dev/null +++ b/packages/cli/test/helpers/read-output-stream.ts @@ -0,0 +1,23 @@ +import { MockClient } from '../mocks/client'; + +export function readOutputStream( + client: MockClient, + length: number = 3 +): Promise { + 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); + }); +} diff --git a/packages/cli/test/integration.js b/packages/cli/test/integration.js index b5ce4c632..3b6e4dd9d 100644 --- a/packages/cli/test/integration.js +++ b/packages/cli/test/integration.js @@ -3517,7 +3517,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 }) ); }); diff --git a/packages/cli/test/mocks/project.ts b/packages/cli/test/mocks/project.ts index 9ead61660..9163f8c4f 100644 --- a/packages/cli/test/mocks/project.ts +++ b/packages/cli/test/mocks/project.ts @@ -157,6 +157,64 @@ export function useProject(project: Partial = 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 }; } diff --git a/packages/cli/test/unit/commands/list.test.ts b/packages/cli/test/unit/commands/list.test.ts index f8cc81ee0..5ea35e748 100644 --- a/packages/cli/test/unit/commands/list.test.ts +++ b/packages/cli/test/unit/commands/list.test.ts @@ -1,10 +1,12 @@ -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 { parseTable, getDataFromIntro } from '../../helpers/parse-table'; const fixture = (name: string) => join(__dirname, '../../fixtures/unit/commands/list', name); @@ -98,42 +100,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 { - 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); - }); -} diff --git a/packages/cli/test/unit/commands/project.test.ts b/packages/cli/test/unit/commands/project.test.ts new file mode 100644 index 000000000..8c711cc6e --- /dev/null +++ b/packages/cli/test/unit/commands/project.test.ts @@ -0,0 +1,94 @@ +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 { getDataFromIntro, parseTable } from '../../helpers/parse-table'; + +describe('projects', () => { + 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 } = getDataFromIntro(output.split('\n')[0]); + const header: string[] = parseTable(output.split('\n')[2]); + const data: string[] = parseTable(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 } = getDataFromIntro(output.split('\n')[0]); + const header: string[] = parseTable(output.split('\n')[2]); + const data: string[] = parseTable(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); + }); + }); +});