diff --git a/packages/build-utils/src/types.ts b/packages/build-utils/src/types.ts index 40d97737d..23564ce8f 100644 --- a/packages/build-utils/src/types.ts +++ b/packages/build-utils/src/types.ts @@ -335,6 +335,7 @@ export interface ProjectSettings { directoryListing?: boolean; gitForkProtection?: boolean; commandForIgnoringBuildStep?: string | null; + skipGitConnectDuringLink?: boolean; } export interface BuilderV2 { diff --git a/packages/cli/src/commands/git/connect.ts b/packages/cli/src/commands/git/connect.ts index 1a6490b81..10d556733 100644 --- a/packages/cli/src/commands/git/connect.ts +++ b/packages/cli/src/commands/git/connect.ts @@ -12,7 +12,7 @@ import { connectGitProvider, disconnectGitProvider, formatProvider, - ParsedRepoUrl, + RepoInfo, parseRepoUrl, printRemoteUrls, } from '../../util/git/connect-git-provider'; @@ -35,7 +35,7 @@ interface ConnectArgParams { org: Org; project: Project; confirm: boolean; - repoInfo: ParsedRepoUrl; + repoInfo: RepoInfo; } interface ConnectGitArgParams extends ConnectArgParams { @@ -45,7 +45,7 @@ interface ConnectGitArgParams extends ConnectArgParams { interface PromptConnectArgParams { client: Client; yes: boolean; - repoInfo: ParsedRepoUrl; + repoInfo: RepoInfo; remoteUrls: Dictionary; } @@ -155,8 +155,8 @@ export default async function connect( output.log(`Connecting Git remote: ${link(remoteUrl)}`); - const parsedUrl = parseRepoUrl(remoteUrl); - if (!parsedUrl) { + const repoInfo = parseRepoUrl(remoteUrl); + if (!repoInfo) { output.error( `Failed to parse Git repo data from the following remote URL: ${link( remoteUrl @@ -164,7 +164,7 @@ export default async function connect( ); return 1; } - const { provider, org: gitOrg, repo } = parsedUrl; + const { provider, org: gitOrg, repo } = repoInfo; const repoPath = `${gitOrg}/${repo}`; const checkAndConnect = await checkExistsAndConnect({ diff --git a/packages/cli/src/util/git/connect-git-provider.ts b/packages/cli/src/util/git/connect-git-provider.ts index 8bd1bc95d..d185dd7db 100644 --- a/packages/cli/src/util/git/connect-git-provider.ts +++ b/packages/cli/src/util/git/connect-git-provider.ts @@ -4,10 +4,10 @@ import { Org } from '../../types'; import chalk from 'chalk'; import link from '../output/link'; import { isAPIError } from '../errors-ts'; -import { Dictionary } from '@vercel/client'; import { Output } from '../output'; +import { Dictionary } from '@vercel/client'; -export interface ParsedRepoUrl { +export interface RepoInfo { url: string; provider: string; org: string; @@ -19,7 +19,7 @@ export async function disconnectGitProvider( org: Org, projectId: string ) { - const fetchUrl = `/v4/projects/${projectId}/link?${stringify({ + const fetchUrl = `/v9/projects/${projectId}/link?${stringify({ teamId: org.type === 'team' ? org.id : undefined, })}`; return client.fetch(fetchUrl, { @@ -37,7 +37,7 @@ export async function connectGitProvider( type: string, repo: string ) { - const fetchUrl = `/v4/projects/${projectId}/link?${stringify({ + const fetchUrl = `/v9/projects/${projectId}/link?${stringify({ teamId: org.type === 'team' ? org.id : undefined, })}`; try { @@ -52,22 +52,21 @@ export async function connectGitProvider( }), }); } catch (err: unknown) { - if (isAPIError(err)) { - if ( - err.action === 'Install GitHub App' || - err.code === 'repo_not_found' - ) { - client.output.error( - `Failed to link ${chalk.cyan( - repo - )}. Make sure there aren't any typos and that you have access to the repository if it's private.` - ); - } else if (err.action === 'Add a Login Connection') { - client.output.error( - err.message.replace(repo, chalk.cyan(repo)) + - `\nVisit ${link(err.link)} for more information.` - ); - } + const apiError = isAPIError(err); + if ( + apiError && + (err.action === 'Install GitHub App' || err.code === 'repo_not_found') + ) { + client.output.error( + `Failed to link ${chalk.cyan( + repo + )}. Make sure there aren't any typos and that you have access to the repository if it's private.` + ); + } else if (apiError && err.action === 'Add a Login Connection') { + client.output.error( + err.message.replace(repo, chalk.cyan(repo)) + + `\nVisit ${link(err.link)} for more information.` + ); } else { client.output.error( `Failed to connect the ${formatProvider( @@ -92,7 +91,7 @@ export function formatProvider(type: string): string { } } -export function parseRepoUrl(originUrl: string): ParsedRepoUrl | null { +export function parseRepoUrl(originUrl: string): RepoInfo | null { const isSSH = originUrl.startsWith('git@'); // Matches all characters between (// or @) and (.com or .org) // eslint-disable-next-line prefer-named-capture-group @@ -126,7 +125,6 @@ export function parseRepoUrl(originUrl: string): ParsedRepoUrl | null { repo, }; } - export function printRemoteUrls( output: Output, remoteUrls: Dictionary diff --git a/packages/cli/src/util/link/add-git-connection.ts b/packages/cli/src/util/link/add-git-connection.ts new file mode 100644 index 000000000..08c951f87 --- /dev/null +++ b/packages/cli/src/util/link/add-git-connection.ts @@ -0,0 +1,107 @@ +import { Dictionary } from '@vercel/client'; +import { parseRepoUrl } from '../git/connect-git-provider'; +import Client from '../client'; +import { Org, Project, ProjectSettings } from '../../types'; +import { handleOptions } from './handle-options'; +import { + promptGitConnectMultipleUrls, + promptGitConnectSingleUrl, +} from './git-connect-prompts'; + +function getProjectSettings(project: Project): ProjectSettings { + return { + createdAt: project.createdAt, + framework: project.framework, + devCommand: project.devCommand, + installCommand: project.installCommand, + buildCommand: project.buildCommand, + outputDirectory: project.outputDirectory, + rootDirectory: project.rootDirectory, + directoryListing: project.directoryListing, + nodeVersion: project.nodeVersion, + skipGitConnectDuringLink: project.skipGitConnectDuringLink, + }; +} + +export async function addGitConnection( + client: Client, + org: Org, + project: Project, + remoteUrls: Dictionary, + settings?: ProjectSettings +): Promise { + if (!settings) { + settings = getProjectSettings(project); + } + if (Object.keys(remoteUrls).length === 1) { + return addSingleGitRemote( + client, + org, + project, + remoteUrls, + settings || project + ); + } else if (Object.keys(remoteUrls).length > 1 && !project.link) { + return addMultipleGitRemotes( + client, + org, + project, + remoteUrls, + settings || project + ); + } +} + +async function addSingleGitRemote( + client: Client, + org: Org, + project: Project, + remoteUrls: Dictionary, + settings: ProjectSettings +) { + const [remoteName, remoteUrl] = Object.entries(remoteUrls)[0]; + const repoInfo = parseRepoUrl(remoteUrl); + if (!repoInfo) { + client.output.debug(`Could not parse repo url ${repoInfo}.`); + return 1; + } + const { org: parsedOrg, repo, provider } = repoInfo; + const alreadyLinked = + project.link && + project.link.org === parsedOrg && + project.link.repo === repo && + project.link.type === provider; + if (alreadyLinked) { + client.output.debug('Project already linked. Skipping...'); + return; + } + + const replace = + project.link && + (project.link.org !== parsedOrg || + project.link.repo !== repo || + project.link.type !== provider); + const shouldConnect = await promptGitConnectSingleUrl( + client, + project, + remoteName, + remoteUrl, + replace + ); + return handleOptions(shouldConnect, client, org, project, settings, repoInfo); +} + +async function addMultipleGitRemotes( + client: Client, + org: Org, + project: Project, + remoteUrls: Dictionary, + settings: ProjectSettings +) { + client.output.log('Found multiple Git remote URLs in Git config.'); + const remoteUrlOrOptions = await promptGitConnectMultipleUrls( + client, + remoteUrls + ); + return handleOptions(remoteUrlOrOptions, client, org, project, settings); +} diff --git a/packages/cli/src/util/link/git-connect-prompts.ts b/packages/cli/src/util/link/git-connect-prompts.ts new file mode 100644 index 000000000..db6ef06be --- /dev/null +++ b/packages/cli/src/util/link/git-connect-prompts.ts @@ -0,0 +1,86 @@ +import { Dictionary } from '@vercel/client'; +import chalk from 'chalk'; +import { Project } from '../../types'; +import Client from '../client'; +import { formatProvider } from '../git/connect-git-provider'; +import list from '../input/list'; +export async function promptGitConnectSingleUrl( + client: Client, + project: Project, + remoteName: string, + remoteUrl: string, + hasDiffConnectedProvider = false +) { + const { output } = client; + if (hasDiffConnectedProvider) { + const currentRepoPath = `${project.link!.org}/${project.link!.repo}`; + const currentProvider = project.link!.type; + output.print('\n'); + output.log( + `Found Git remote URL ${chalk.cyan( + remoteUrl + )}, which is different from the connected ${formatProvider( + currentProvider + )} repository ${chalk.cyan(currentRepoPath)}.` + ); + } else { + output.print('\n'); + output.log( + `Found local Git remote "${remoteName}": ${chalk.cyan(remoteUrl)}` + ); + } + return await list(client, { + message: hasDiffConnectedProvider + ? 'Do you want to replace it?' + : `Do you want to connect "${remoteName}" to your Vercel project?`, + choices: [ + { + name: 'Yes', + value: 'yes', + short: 'yes', + }, + { + name: 'No', + value: 'no', + short: 'no', + }, + { + name: 'Do not ask again for this project', + value: 'opt-out', + short: 'no (opt out)', + }, + ], + }); +} + +export async function promptGitConnectMultipleUrls( + client: Client, + remoteUrls: Dictionary +) { + const staticOptions = [ + { + name: 'No', + value: 'no', + short: 'no', + }, + { + name: 'Do not ask again for this project', + value: 'opt-out', + short: 'no (opt out)', + }, + ]; + let choices = []; + for (const url of Object.values(remoteUrls)) { + choices.push({ + name: url, + value: url, + short: url, + }); + } + choices = choices.concat(staticOptions); + + return await list(client, { + message: 'Do you want to connect a Git repository to your Vercel project?', + choices, + }); +} diff --git a/packages/cli/src/util/link/handle-options.ts b/packages/cli/src/util/link/handle-options.ts new file mode 100644 index 000000000..06239deaf --- /dev/null +++ b/packages/cli/src/util/link/handle-options.ts @@ -0,0 +1,98 @@ +import chalk from 'chalk'; +import { Org, Project, ProjectSettings } from '../../types'; +import Client from '../client'; +import { + connectGitProvider, + disconnectGitProvider, + formatProvider, + RepoInfo, + parseRepoUrl, +} from '../git/connect-git-provider'; +import { Output } from '../output'; +import { getCommandName } from '../pkg-name'; +import updateProject from '../projects/update-project'; + +export async function handleOptions( + option: string, + client: Client, + org: Org, + project: Project, + settings: ProjectSettings, + repoInfo?: RepoInfo +) { + const { output } = client; + if (option === 'no') { + skip(output); + return; + } else if (option === 'opt-out') { + optOut(client, project, settings); + return; + } else if (option !== '') { + // Option is "yes" or a URL + + // Ensure parsed url exists + if (!repoInfo) { + const _repoInfo = parseRepoUrl(option); + if (!_repoInfo) { + output.debug(`Could not parse repo url ${option}.`); + return 1; + } + repoInfo = _repoInfo; + } + return connect(client, org, project, repoInfo); + } +} + +async function optOut( + client: Client, + project: Project, + settings: ProjectSettings +) { + settings.skipGitConnectDuringLink = true; + await updateProject(client, project.name, settings); + client.output + .log(`Opted out. You can re-enable this prompt by visiting the Settings > Git page on the + dashboard for this Project.`); +} + +function skip(output: Output) { + output.log('Skipping...'); + output.log( + `You can connect a Git repository in the future by running ${getCommandName( + 'git connect' + )}.` + ); +} + +async function connect( + client: Client, + org: Org, + project: Project, + repoInfo: RepoInfo +): Promise { + const { output } = client; + const { provider, org: parsedOrg, repo } = repoInfo; + const repoPath = `${parsedOrg}/${repo}`; + + output.log('Connecting...'); + + if (project.link) { + await disconnectGitProvider(client, org, project.id); + } + const connect = await connectGitProvider( + client, + org, + project.id, + provider, + repoPath + ); + if (connect !== 1) { + output.log( + `Connected ${formatProvider(provider)} repository ${chalk.cyan( + repoPath + )}!` + ); + } else { + return connect; + } +} diff --git a/packages/cli/src/util/link/setup-and-link.ts b/packages/cli/src/util/link/setup-and-link.ts index 3e4ad3d22..fc83cf05f 100644 --- a/packages/cli/src/util/link/setup-and-link.ts +++ b/packages/cli/src/util/link/setup-and-link.ts @@ -28,6 +28,8 @@ import { EmojiLabel } from '../emoji'; import createDeploy from '../deploy/create-deploy'; import Now, { CreateOptions } from '../index'; import { isAPIError } from '../errors-ts'; +import { getRemoteUrls } from '../create-git-meta'; +import { addGitConnection } from './add-git-connection'; export interface SetupAndLinkOptions { forceDelete?: boolean; @@ -128,6 +130,19 @@ export default async function setupAndLink( } else { const project = projectOrNewProjectName; + const remoteUrls = await getRemoteUrls(join(path, '.git/config'), output); + if (remoteUrls && !project.skipGitConnectDuringLink) { + const connectGit = await addGitConnection( + client, + org, + project, + remoteUrls + ); + if (typeof connectGit === 'number') { + return { status: 'error', exitCode: connectGit }; + } + } + await linkFolderToProject( output, path, @@ -241,6 +256,21 @@ export default async function setupAndLink( } const project = await createProject(client, newProjectName); + + const remoteUrls = await getRemoteUrls(join(path, '.git/config'), output); + if (remoteUrls) { + const connectGit = await addGitConnection( + client, + org, + project, + remoteUrls, + settings + ); + if (typeof connectGit === 'number') { + return { status: 'error', exitCode: connectGit }; + } + } + await updateProject(client, project.id, settings); Object.assign(project, settings); diff --git a/packages/cli/src/util/projects/create-project.ts b/packages/cli/src/util/projects/create-project.ts index 19bd8d053..6eaa86556 100644 --- a/packages/cli/src/util/projects/create-project.ts +++ b/packages/cli/src/util/projects/create-project.ts @@ -7,7 +7,7 @@ export default async function createProject( ) { const project = await client.fetch('/v1/projects', { method: 'POST', - body: JSON.stringify({ name: projectName }), + body: { name: projectName }, }); return project; } diff --git a/packages/cli/src/util/projects/update-project.ts b/packages/cli/src/util/projects/update-project.ts index e96d9c27f..77879938a 100644 --- a/packages/cli/src/util/projects/update-project.ts +++ b/packages/cli/src/util/projects/update-project.ts @@ -1,5 +1,5 @@ import Client from '../client'; -import { ProjectSettings } from '../../types'; +import type { JSONObject, ProjectSettings } from '../../types'; interface ProjectSettingsResponse extends ProjectSettings { id: string; @@ -13,11 +13,14 @@ export default async function updateProject( prjNameOrId: string, settings: ProjectSettings ) { + // `ProjectSettings` is technically compatible with JSONObject + const body = settings as JSONObject; + const res = await client.fetch( `/v2/projects/${encodeURIComponent(prjNameOrId)}`, { method: 'PATCH', - body: JSON.stringify(settings), + body, } ); return res; diff --git a/packages/cli/test/fixtures/unit/link-connect-git/multiple-remotes/.gitignore b/packages/cli/test/fixtures/unit/link-connect-git/multiple-remotes/.gitignore new file mode 100644 index 000000000..7b54c6705 --- /dev/null +++ b/packages/cli/test/fixtures/unit/link-connect-git/multiple-remotes/.gitignore @@ -0,0 +1,2 @@ +!.vercel +.vercel diff --git a/packages/cli/test/fixtures/unit/link-connect-git/multiple-remotes/git/config b/packages/cli/test/fixtures/unit/link-connect-git/multiple-remotes/git/config new file mode 100644 index 000000000..c2c0dba42 --- /dev/null +++ b/packages/cli/test/fixtures/unit/link-connect-git/multiple-remotes/git/config @@ -0,0 +1,16 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/user/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +[remote "secondary"] + url = https://github.com/user2/repo2.git + fetch = +refs/heads/*:refs/remotes/secondary/* +[remote "gitlab"] + url = https://gitlab.com/user/repo.git + fetch = +refs/heads/*:refs/remotes/gitlab/* \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/link-connect-git/single-remote-existing-link/.gitignore b/packages/cli/test/fixtures/unit/link-connect-git/single-remote-existing-link/.gitignore new file mode 100644 index 000000000..7b54c6705 --- /dev/null +++ b/packages/cli/test/fixtures/unit/link-connect-git/single-remote-existing-link/.gitignore @@ -0,0 +1,2 @@ +!.vercel +.vercel diff --git a/packages/cli/test/fixtures/unit/link-connect-git/single-remote-existing-link/git/config b/packages/cli/test/fixtures/unit/link-connect-git/single-remote-existing-link/git/config new file mode 100644 index 000000000..fd945011a --- /dev/null +++ b/packages/cli/test/fixtures/unit/link-connect-git/single-remote-existing-link/git/config @@ -0,0 +1,13 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/user2/repo2.git + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "master"] + remote = origin + merge = refs/heads/master \ No newline at end of file diff --git a/packages/cli/test/fixtures/unit/link-connect-git/single-remote/.gitignore b/packages/cli/test/fixtures/unit/link-connect-git/single-remote/.gitignore new file mode 100644 index 000000000..7b54c6705 --- /dev/null +++ b/packages/cli/test/fixtures/unit/link-connect-git/single-remote/.gitignore @@ -0,0 +1,2 @@ +!.vercel +.vercel diff --git a/packages/cli/test/fixtures/unit/link-connect-git/single-remote/git/config b/packages/cli/test/fixtures/unit/link-connect-git/single-remote/git/config new file mode 100644 index 000000000..887a8b0e1 --- /dev/null +++ b/packages/cli/test/fixtures/unit/link-connect-git/single-remote/git/config @@ -0,0 +1,13 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true + ignorecase = true + precomposeunicode = true +[remote "origin"] + url = https://github.com/user/repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "master"] + remote = origin + merge = refs/heads/master \ No newline at end of file diff --git a/packages/cli/test/mocks/deployment.ts b/packages/cli/test/mocks/deployment.ts index 607f051f7..6ca4daf81 100644 --- a/packages/cli/test/mocks/deployment.ts +++ b/packages/cli/test/mocks/deployment.ts @@ -49,6 +49,43 @@ export function useDeployment({ return deployment; } +export function useDeploymentMissingProjectSettings() { + client.scenario.post('/:version/deployments', (_req, res) => { + res.status(400).json({ + error: { + code: 'missing_project_settings', + message: + 'The `projectSettings` object is required for new projects, but is missing in the deployment payload', + framework: { + name: 'Other', + slug: null, + logo: 'https://raw.githubusercontent.com/vercel/vercel/main/packages/frameworks/logos/other.svg', + description: 'No framework or an unoptimized framework.', + settings: { + installCommand: { + placeholder: '`yarn install`, `pnpm install`, or `npm install`', + }, + buildCommand: { + placeholder: '`npm run vercel-build` or `npm run build`', + value: null, + }, + devCommand: { placeholder: 'None', value: null }, + outputDirectory: { placeholder: '`public` if it exists, or `.`' }, + }, + }, + projectSettings: { + devCommand: null, + installCommand: null, + buildCommand: null, + outputDirectory: null, + rootDirectory: null, + framework: null, + }, + }, + }); + }); +} + beforeEach(() => { deployments = new Map(); deploymentBuilds = new Map(); diff --git a/packages/cli/test/mocks/project.ts b/packages/cli/test/mocks/project.ts index 9edefcd72..76c362577 100644 --- a/packages/cli/test/mocks/project.ts +++ b/packages/cli/test/mocks/project.ts @@ -131,6 +131,70 @@ export const defaultProject = { ], }; +/** + * Responds to any GET for a project with a 404. + * `useUnknownProject` should always come after `useProject`, if any, + * to allow `useProject` responses to still happen. + */ +export function useUnknownProject() { + let project: Project; + client.scenario.get(`/v8/projects/:projectNameOrId`, (_req, res) => { + res.status(404).send(); + }); + client.scenario.post(`/:version/projects`, (req, res) => { + const { name } = req.body; + project = { + ...defaultProject, + name, + id: name, + }; + res.json(project); + }); + client.scenario.post(`/v9/projects/:projectNameOrId/link`, (req, res) => { + const { type, repo, org } = req.body; + const projName = req.params.projectNameOrId; + if (projName !== project.name && projName !== project.id) { + return res.status(404).send('Invalid Project name or ID'); + } + 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`, + 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.patch(`/:version/projects/:projectNameOrId`, (req, res) => { + Object.assign(project, req.body); + res.json(project); + }); +} + export function useProject(project: Partial = defaultProject) { client.scenario.get(`/v8/projects/${project.name}`, (_req, res) => { res.json(project); @@ -138,6 +202,10 @@ export function useProject(project: Partial = defaultProject) { client.scenario.get(`/v8/projects/${project.id}`, (_req, res) => { res.json(project); }); + client.scenario.patch(`/:version/projects/${project.id}`, (req, res) => { + Object.assign(project, req.body); + res.json(project); + }); client.scenario.get( `/v6/projects/${project.id}/system-env-values`, (_req, res) => { @@ -183,7 +251,7 @@ export function useProject(project: Partial = defaultProject) { res.json(envs); } ); - client.scenario.post(`/v4/projects/${project.id}/link`, (req, res) => { + client.scenario.post(`/v9/projects/${project.id}/link`, (req, res) => { const { type, repo, org } = req.body; if ( (type === 'github' || type === 'gitlab' || type === 'bitbucket') && @@ -218,7 +286,7 @@ export function useProject(project: Partial = defaultProject) { } } }); - client.scenario.delete(`/v4/projects/${project.id}/link`, (_req, res) => { + client.scenario.delete(`/v9/projects/${project.id}/link`, (_req, res) => { if (project.link) { project.link = undefined; } diff --git a/packages/cli/test/unit/commands/git.test.ts b/packages/cli/test/unit/commands/git.test.ts index 14dbb3ae2..6863fc2cd 100644 --- a/packages/cli/test/unit/commands/git.test.ts +++ b/packages/cli/test/unit/commands/git.test.ts @@ -39,6 +39,11 @@ describe('git', () => { await expect(client.stderr).toOutput('Found project'); client.stdin.write('y\n'); + await expect(client.stderr).toOutput( + 'Do you want to connect "origin" to your Vercel project?' + ); + client.stdin.write('n\n'); + await expect(client.stderr).toOutput( `Connecting Git remote: https://github.com/user/repo.git` ); diff --git a/packages/cli/test/unit/commands/link.test.ts b/packages/cli/test/unit/commands/link.test.ts new file mode 100644 index 000000000..c48ddbfc7 --- /dev/null +++ b/packages/cli/test/unit/commands/link.test.ts @@ -0,0 +1,310 @@ +import { join } from 'path'; +import fs from 'fs-extra'; +import link from '../../../src/commands/link'; +import { useUser } from '../../mocks/user'; +import { useTeams } from '../../mocks/team'; +import { + defaultProject, + useUnknownProject, + useProject, +} from '../../mocks/project'; +import { client } from '../../mocks/client'; +import { useDeploymentMissingProjectSettings } from '../../mocks/deployment'; +import { Project } from '../../../src/types'; + +describe('link', () => { + describe('git prompt', () => { + const originalCwd = process.cwd(); + const fixture = (name: string) => + join(__dirname, '../../fixtures/unit/link-connect-git', name); + + it('should prompt to connect a new project with a single remote', async () => { + const cwd = fixture('single-remote'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + useUser(); + useUnknownProject(); + useDeploymentMissingProjectSettings(); + useTeams('team_dummy'); + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Link to existing project?'); + client.stdin.write('n\n'); + await expect(client.stderr).toOutput('What’s your project’s name?'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput( + 'In which directory is your code located?' + ); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Want to modify these settings?'); + client.stdin.write('n\n'); + + await expect(client.stderr).toOutput( + 'Found local Git remote "origin": https://github.com/user/repo.git' + ); + await expect(client.stderr).toOutput( + 'Do you want to connect "origin" to your Vercel project?' + ); + client.stdin.write('\r'); + await expect(client.stderr).toOutput( + 'Connected GitHub repository user/repo!' + ); + await expect(client.stderr).toOutput('Linked to'); + await expect(linkPromise).resolves.toEqual(0); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + + it('should prompt to connect an existing project with a single remote to git', async () => { + const cwd = fixture('single-remote'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + useUser(); + useProject({ + ...defaultProject, + name: 'single-remote', + id: 'single-remote', + }); + useTeams('team_dummy'); + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Found project'); + client.stdin.write('y\n'); + + await expect(client.stderr).toOutput( + 'Found local Git remote "origin": https://github.com/user/repo.git' + ); + await expect(client.stderr).toOutput( + 'Do you want to connect "origin" to your Vercel project?' + ); + client.stdin.write('\r'); + await expect(client.stderr).toOutput( + 'Connected GitHub repository user/repo!' + ); + await expect(client.stderr).toOutput('Linked to'); + + await expect(linkPromise).resolves.toEqual(0); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + it('should prompt to replace a connected repository if there is one remote', async () => { + const cwd = fixture('single-remote-existing-link'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + useUser(); + const project = useProject({ + ...defaultProject, + name: 'single-remote-existing-link', + id: 'single-remote-existing-link', + }); + useTeams('team_dummy'); + project.project.link = { + type: 'github', + org: 'user', + repo: 'repo', + repoId: 1010, + gitCredentialId: '', + sourceless: true, + createdAt: 1656109539791, + updatedAt: 1656109539791, + }; + + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Found project'); + client.stdin.write('y\n'); + + await expect(client.stderr).toOutput( + `Found Git remote URL https://github.com/user2/repo2.git, which is different from the connected GitHub repository user/repo.` + ); + await expect(client.stderr).toOutput('Do you want to replace it?'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput( + 'Connected GitHub repository user2/repo2!' + ); + await expect(client.stderr).toOutput('Linked to'); + + await expect(linkPromise).resolves.toEqual(0); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + it('should prompt to connect an existing project with multiple remotes', async () => { + const cwd = fixture('multiple-remotes'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + + useUser(); + useProject({ + ...defaultProject, + name: 'multiple-remotes', + id: 'multiple-remotes', + }); + useTeams('team_dummy'); + + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Found project'); + client.stdin.write('y\n'); + + await expect(client.stderr).toOutput( + `> Do you want to connect a Git repository to your Vercel project?` + ); + client.stdin.write('\r'); + await expect(client.stderr).toOutput( + 'Connected GitHub repository user/repo!' + ); + await expect(client.stderr).toOutput('Linked to'); + + await expect(linkPromise).resolves.toEqual(0); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + it('should not prompt to replace a connected repository if there is more than one remote', async () => { + const cwd = fixture('multiple-remotes'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + + useUser(); + const project = useProject({ + ...defaultProject, + name: 'multiple-remotes', + id: 'multiple-remotes', + }); + useTeams('team_dummy'); + project.project.link = { + type: 'github', + org: 'user', + repo: 'repo', + repoId: 1010, + gitCredentialId: '', + sourceless: true, + createdAt: 1656109539791, + updatedAt: 1656109539791, + }; + + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Found project'); + client.stdin.write('y\n'); + + expect(client.stderr).not.toOutput('Found multiple Git remote URLs'); + await expect(client.stderr).toOutput('Linked to'); + + await expect(linkPromise).resolves.toEqual(0); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + it('should set a project setting if user opts out', async () => { + const cwd = fixture('single-remote'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + + useUser(); + useProject({ + ...defaultProject, + name: 'single-remote', + id: 'single-remote', + }); + useTeams('team_dummy'); + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Found project'); + client.stdin.write('y\n'); + + await expect(client.stderr).toOutput( + 'Found local Git remote "origin": https://github.com/user/repo.git' + ); + await expect(client.stderr).toOutput( + 'Do you want to connect "origin" to your Vercel project?' + ); + client.stdin.write('\x1B[B'); // Down arrow + client.stdin.write('\x1B[B'); + client.stdin.write('\r'); // Opt out + + await expect(client.stderr).toOutput(`Opted out.`); + await expect(client.stderr).toOutput('Linked to'); + await expect(linkPromise).resolves.toEqual(0); + + const newProjectData: Project = await client.fetch( + `/v8/projects/single-remote` + ); + expect(newProjectData.skipGitConnectDuringLink).toBeTruthy(); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + it('should not prompt to connect git if the project has skipGitConnectDuringLink property', async () => { + const cwd = fixture('single-remote'); + try { + process.chdir(cwd); + await fs.rename(join(cwd, 'git'), join(cwd, '.git')); + + useUser(); + const project = useProject({ + ...defaultProject, + name: 'single-remote', + id: 'single-remote', + }); + useTeams('team_dummy'); + project.project.skipGitConnectDuringLink = true; + const linkPromise = link(client); + + await expect(client.stderr).toOutput('Set up'); + client.stdin.write('y\n'); + await expect(client.stderr).toOutput('Which scope'); + client.stdin.write('\r'); + await expect(client.stderr).toOutput('Found project'); + client.stdin.write('y\n'); + + expect(client.stderr).not.toOutput('Found local Git remote "origin"'); + + await expect(client.stderr).toOutput('Linked to'); + await expect(linkPromise).resolves.toEqual(0); + } finally { + await fs.rename(join(cwd, '.git'), join(cwd, 'git')); + process.chdir(originalCwd); + } + }); + }); +}); diff --git a/packages/cli/test/unit/util/deploy/create-git-meta.test.ts b/packages/cli/test/unit/util/deploy/create-git-meta.test.ts index 6f2d31d96..4a8e3f612 100644 --- a/packages/cli/test/unit/util/deploy/create-git-meta.test.ts +++ b/packages/cli/test/unit/util/deploy/create-git-meta.test.ts @@ -58,92 +58,92 @@ describe('getRemoteUrls', () => { describe('parseRepoUrl', () => { it('should be null when a url does not match the regex', () => { - const parsedUrl = parseRepoUrl('https://examplecom/foo'); - expect(parsedUrl).toBeNull(); + const repoInfo = parseRepoUrl('https://examplecom/foo'); + expect(repoInfo).toBeNull(); }); it('should be null when a url does not contain org and repo data', () => { - const parsedUrl = parseRepoUrl('https://github.com/borked'); - expect(parsedUrl).toBeNull(); + const repoInfo = parseRepoUrl('https://github.com/borked'); + expect(repoInfo).toBeNull(); }); it('should parse url with a period in the repo name', () => { - const parsedUrl = parseRepoUrl('https://github.com/vercel/next.js'); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('github'); - expect(parsedUrl?.org).toEqual('vercel'); - expect(parsedUrl?.repo).toEqual('next.js'); + const repoInfo = parseRepoUrl('https://github.com/vercel/next.js'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('github'); + expect(repoInfo?.org).toEqual('vercel'); + expect(repoInfo?.repo).toEqual('next.js'); }); it('should parse url that ends with .git', () => { - const parsedUrl = parseRepoUrl('https://github.com/vercel/next.js.git'); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('github'); - expect(parsedUrl?.org).toEqual('vercel'); - expect(parsedUrl?.repo).toEqual('next.js'); + const repoInfo = parseRepoUrl('https://github.com/vercel/next.js.git'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('github'); + expect(repoInfo?.org).toEqual('vercel'); + expect(repoInfo?.repo).toEqual('next.js'); }); it('should parse github https url', () => { - const parsedUrl = parseRepoUrl('https://github.com/vercel/vercel.git'); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('github'); - expect(parsedUrl?.org).toEqual('vercel'); - expect(parsedUrl?.repo).toEqual('vercel'); + const repoInfo = parseRepoUrl('https://github.com/vercel/vercel.git'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('github'); + expect(repoInfo?.org).toEqual('vercel'); + expect(repoInfo?.repo).toEqual('vercel'); }); it('should parse github https url without the .git suffix', () => { - const parsedUrl = parseRepoUrl('https://github.com/vercel/vercel'); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('github'); - expect(parsedUrl?.org).toEqual('vercel'); - expect(parsedUrl?.repo).toEqual('vercel'); + const repoInfo = parseRepoUrl('https://github.com/vercel/vercel'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('github'); + expect(repoInfo?.org).toEqual('vercel'); + expect(repoInfo?.repo).toEqual('vercel'); }); it('should parse github git url', () => { - const parsedUrl = parseRepoUrl('git://github.com/vercel/vercel.git'); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('github'); - expect(parsedUrl?.org).toEqual('vercel'); - expect(parsedUrl?.repo).toEqual('vercel'); + const repoInfo = parseRepoUrl('git://github.com/vercel/vercel.git'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('github'); + expect(repoInfo?.org).toEqual('vercel'); + expect(repoInfo?.repo).toEqual('vercel'); }); it('should parse github ssh url', () => { - const parsedUrl = parseRepoUrl('git@github.com:vercel/vercel.git'); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('github'); - expect(parsedUrl?.org).toEqual('vercel'); - expect(parsedUrl?.repo).toEqual('vercel'); + const repoInfo = parseRepoUrl('git@github.com:vercel/vercel.git'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('github'); + expect(repoInfo?.org).toEqual('vercel'); + expect(repoInfo?.repo).toEqual('vercel'); }); it('should parse gitlab https url', () => { - const parsedUrl = parseRepoUrl( + const repoInfo = parseRepoUrl( 'https://gitlab.com/gitlab-examples/knative-kotlin-app.git' ); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('gitlab'); - expect(parsedUrl?.org).toEqual('gitlab-examples'); - expect(parsedUrl?.repo).toEqual('knative-kotlin-app'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('gitlab'); + expect(repoInfo?.org).toEqual('gitlab-examples'); + expect(repoInfo?.repo).toEqual('knative-kotlin-app'); }); it('should parse gitlab ssh url', () => { - const parsedUrl = parseRepoUrl( + const repoInfo = parseRepoUrl( 'git@gitlab.com:gitlab-examples/knative-kotlin-app.git' ); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('gitlab'); - expect(parsedUrl?.org).toEqual('gitlab-examples'); - expect(parsedUrl?.repo).toEqual('knative-kotlin-app'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('gitlab'); + expect(repoInfo?.org).toEqual('gitlab-examples'); + expect(repoInfo?.repo).toEqual('knative-kotlin-app'); }); it('should parse bitbucket https url', () => { - const parsedUrl = parseRepoUrl( + const repoInfo = parseRepoUrl( 'https://bitbucket.org/atlassianlabs/maven-project-example.git' ); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('bitbucket'); - expect(parsedUrl?.org).toEqual('atlassianlabs'); - expect(parsedUrl?.repo).toEqual('maven-project-example'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('bitbucket'); + expect(repoInfo?.org).toEqual('atlassianlabs'); + expect(repoInfo?.repo).toEqual('maven-project-example'); }); it('should parse bitbucket ssh url', () => { - const parsedUrl = parseRepoUrl( + const repoInfo = parseRepoUrl( 'git@bitbucket.org:atlassianlabs/maven-project-example.git' ); - expect(parsedUrl).toBeDefined(); - expect(parsedUrl?.provider).toEqual('bitbucket'); - expect(parsedUrl?.org).toEqual('atlassianlabs'); - expect(parsedUrl?.repo).toEqual('maven-project-example'); + expect(repoInfo).toBeDefined(); + expect(repoInfo?.provider).toEqual('bitbucket'); + expect(repoInfo?.org).toEqual('atlassianlabs'); + expect(repoInfo?.repo).toEqual('maven-project-example'); }); it('should parse url without a scheme', () => { const parsedUrl = parseRepoUrl('github.com/user/repo');