Skip to content

Commit 32ebcd8

Browse files
[cli] Add vc project connect command (vercel#8014)
This PR adds a new subcommand, `vc project connect`, which connects a Git provider repository to the current project. Previously, this could only be done via the Dashboard. This is the first part of a larger project—the goal is to include this functionality within `vc link`, so that you never have to leave the CLI if you want to set up a new Vercel project that's connected to Git. ### 📋 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
1 parent 2e43b2b commit 32ebcd8

File tree

32 files changed

+772
-25
lines changed

32 files changed

+772
-25
lines changed

packages/cli/src/commands/deploy/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import { help } from './args';
6464
import { getDeploymentChecks } from '../../util/deploy/get-deployment-checks';
6565
import parseTarget from '../../util/deploy/parse-target';
6666
import getPrebuiltJson from '../../util/deploy/get-prebuilt-json';
67-
import { createGitMeta } from '../../util/deploy/create-git-meta';
67+
import { createGitMeta } from '../../util/create-git-meta';
6868

6969
export default async (client: Client) => {
7070
const { output } = client;

packages/cli/src/commands/projects.ts

+177-12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ import getScope from '../util/get-scope';
1010
import getCommandFlags from '../util/get-command-flags';
1111
import { getPkgName, getCommandName } from '../util/pkg-name';
1212
import Client from '../util/client';
13+
import validatePaths from '../util/validate-paths';
14+
import { ensureLink } from '../util/ensure-link';
15+
import { parseGitConfig, pluckRemoteUrl } from '../util/create-git-meta';
16+
import {
17+
connectGitProvider,
18+
disconnectGitProvider,
19+
formatProvider,
20+
parseRepoUrl,
21+
} from '../util/projects/connect-git-provider';
22+
import { join } from 'path';
23+
import { Team, User } from '../types';
24+
import confirm from '../util/input/confirm';
25+
import { Output } from '../util/output';
26+
import link from '../util/output/link';
1327

1428
const e = encodeURIComponent;
1529

@@ -20,6 +34,7 @@ const help = () => {
2034
${chalk.dim('Commands:')}
2135
2236
ls Show all projects in the selected team/user
37+
connect Connect a Git provider to your project
2338
add [name] Add a new project
2439
rm [name] Remove a project
2540
@@ -54,6 +69,7 @@ const main = async (client: Client) => {
5469
argv = getArgs(client.argv.slice(2), {
5570
'--next': Number,
5671
'-N': '--next',
72+
'--yes': Boolean,
5773
});
5874
} catch (error) {
5975
handleError(error);
@@ -71,10 +87,10 @@ const main = async (client: Client) => {
7187

7288
const { output } = client;
7389

74-
let contextName = null;
90+
let scope = null;
7591

7692
try {
77-
({ contextName } = await getScope(client));
93+
scope = await getScope(client);
7894
} catch (err) {
7995
if (err.code === 'NOT_AUTHORIZED' || err.code === 'TEAM_DELETED') {
8096
output.error(err.message);
@@ -84,17 +100,12 @@ const main = async (client: Client) => {
84100
throw err;
85101
}
86102

87-
try {
88-
await run({ client, contextName });
89-
} catch (err) {
90-
handleError(err);
91-
exit(1);
92-
}
103+
return await run({ client, scope });
93104
};
94105

95106
export default async (client: Client) => {
96107
try {
97-
await main(client);
108+
return await main(client);
98109
} catch (err) {
99110
handleError(err);
100111
process.exit(1);
@@ -103,16 +114,148 @@ export default async (client: Client) => {
103114

104115
async function run({
105116
client,
106-
contextName,
117+
scope,
107118
}: {
108119
client: Client;
109-
contextName: string;
120+
scope: {
121+
contextName: string;
122+
team: Team | null;
123+
user: User;
124+
};
110125
}) {
111126
const { output } = client;
127+
const { contextName, team } = scope;
112128
const args = argv._.slice(1);
113129

114130
const start = Date.now();
115131

132+
if (subcommand === 'connect') {
133+
const yes = Boolean(argv['--yes']);
134+
if (args.length !== 0) {
135+
output.error(
136+
`Invalid number of arguments. Usage: ${chalk.cyan(
137+
`${getCommandName('project connect')}`
138+
)}`
139+
);
140+
return exit(2);
141+
}
142+
143+
let paths = [process.cwd()];
144+
145+
const validate = await validatePaths(client, paths);
146+
if (!validate.valid) {
147+
return validate.exitCode;
148+
}
149+
const { path } = validate;
150+
151+
const linkedProject = await ensureLink(
152+
'project connect',
153+
client,
154+
path,
155+
yes
156+
);
157+
if (typeof linkedProject === 'number') {
158+
return linkedProject;
159+
}
160+
161+
const { project, org } = linkedProject;
162+
const gitProviderLink = project.link;
163+
164+
client.config.currentTeam = org.type === 'team' ? org.id : undefined;
165+
166+
// get project from .git
167+
const gitConfigPath = join(path, '.git/config');
168+
const gitConfig = await parseGitConfig(gitConfigPath, output);
169+
if (!gitConfig) {
170+
output.error(
171+
`No local git repo found. Run ${chalk.cyan(
172+
'`git clone <url>`'
173+
)} to clone a remote Git repository first.`
174+
);
175+
return 1;
176+
}
177+
const remoteUrl = pluckRemoteUrl(gitConfig);
178+
if (!remoteUrl) {
179+
output.error(
180+
`No remote origin URL found in your Git config. Make sure you've connected your local Git repo to a Git provider first.`
181+
);
182+
return 1;
183+
}
184+
const parsedUrl = parseRepoUrl(remoteUrl);
185+
if (!parsedUrl) {
186+
output.error(
187+
`Failed to parse Git repo data from the following remote URL in your Git config: ${link(
188+
remoteUrl
189+
)}`
190+
);
191+
return 1;
192+
}
193+
const { provider, org: gitOrg, repo } = parsedUrl;
194+
const repoPath = `${gitOrg}/${repo}`;
195+
let connectedRepoPath;
196+
197+
if (!gitProviderLink) {
198+
const connect = await connectGitProvider(
199+
client,
200+
team,
201+
project.id,
202+
provider,
203+
repoPath
204+
);
205+
if (typeof connect === 'number') {
206+
return connect;
207+
}
208+
} else {
209+
const connectedProvider = gitProviderLink.type;
210+
const connectedOrg = gitProviderLink.org;
211+
const connectedRepo = gitProviderLink.repo;
212+
connectedRepoPath = `${connectedOrg}/${connectedRepo}`;
213+
214+
const isSameRepo =
215+
connectedProvider === provider &&
216+
connectedOrg === gitOrg &&
217+
connectedRepo === repo;
218+
if (isSameRepo) {
219+
output.log(
220+
`${chalk.cyan(
221+
connectedRepoPath
222+
)} is already connected to your project.`
223+
);
224+
return 1;
225+
}
226+
227+
const shouldReplaceRepo = await confirmRepoConnect(
228+
client,
229+
output,
230+
yes,
231+
connectedRepoPath
232+
);
233+
if (!shouldReplaceRepo) {
234+
return 0;
235+
}
236+
237+
await disconnectGitProvider(client, team, project.id);
238+
const connect = await connectGitProvider(
239+
client,
240+
team,
241+
project.id,
242+
provider,
243+
repoPath
244+
);
245+
if (typeof connect === 'number') {
246+
return connect;
247+
}
248+
}
249+
250+
output.log(
251+
`Connected ${formatProvider(provider)} repository ${chalk.cyan(
252+
repoPath
253+
)}!`
254+
);
255+
256+
return 0;
257+
}
258+
116259
if (subcommand === 'ls' || subcommand === 'list') {
117260
if (args.length !== 0) {
118261
console.error(
@@ -271,7 +414,7 @@ async function run({
271414
return;
272415
}
273416

274-
console.error(error('Please specify a valid subcommand: ls | add | rm'));
417+
output.error('Please specify a valid subcommand: ls | connect | add | rm');
275418
help();
276419
exit(2);
277420
}
@@ -281,6 +424,28 @@ process.on('uncaughtException', err => {
281424
exit(1);
282425
});
283426

427+
async function confirmRepoConnect(
428+
client: Client,
429+
output: Output,
430+
yes: boolean,
431+
connectedRepoPath: string
432+
) {
433+
let shouldReplaceProject = yes;
434+
if (!shouldReplaceProject) {
435+
shouldReplaceProject = await confirm(
436+
client,
437+
`Looks like you already have a repository connected: ${chalk.cyan(
438+
connectedRepoPath
439+
)}. Do you want to replace it?`,
440+
true
441+
);
442+
if (!shouldReplaceProject) {
443+
output.log(`Aborted. Repo not connected.`);
444+
}
445+
}
446+
return shouldReplaceProject;
447+
}
448+
284449
function readConfirmation(projectName: string) {
285450
return new Promise(resolve => {
286451
process.stdout.write(

packages/cli/src/types.ts

+22
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,34 @@ export interface ProjectEnvVariable {
248248
gitBranch?: string;
249249
}
250250

251+
export interface DeployHook {
252+
createdAt: number;
253+
id: string;
254+
name: string;
255+
ref: string;
256+
url: string;
257+
}
258+
259+
export interface ProjectLinkData {
260+
type: string;
261+
repo: string;
262+
repoId: number;
263+
org?: string;
264+
gitCredentialId: string;
265+
productionBranch?: string | null;
266+
sourceless: boolean;
267+
createdAt: number;
268+
updatedAt: number;
269+
deployHooks?: DeployHook[];
270+
}
271+
251272
export interface Project extends ProjectSettings {
252273
id: string;
253274
name: string;
254275
accountId: string;
255276
updatedAt: number;
256277
createdAt: number;
278+
link?: ProjectLinkData;
257279
alias?: ProjectAliasTarget[];
258280
latestDeployments?: Partial<Deployment>[];
259281
}

packages/cli/src/util/deploy/create-git-meta.ts packages/cli/src/util/create-git-meta.ts

+19-9
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { join } from 'path';
33
import ini from 'ini';
44
import git from 'git-last-commit';
55
import { exec } from 'child_process';
6-
import { GitMetadata } from '../../types';
7-
import { Output } from '../output';
6+
import { GitMetadata } from '../types';
7+
import { Output } from './output';
88

99
export function isDirty(directory: string): Promise<boolean> {
1010
return new Promise((resolve, reject) => {
@@ -33,21 +33,31 @@ function getLastCommit(directory: string): Promise<git.Commit> {
3333
});
3434
}
3535

36-
export async function getRemoteUrl(
37-
configPath: string,
38-
output: Output
39-
): Promise<string | null> {
40-
let gitConfig;
36+
export async function parseGitConfig(configPath: string, output: Output) {
4137
try {
42-
gitConfig = ini.parse(await fs.readFile(configPath, 'utf-8'));
38+
return ini.parse(await fs.readFile(configPath, 'utf-8'));
4339
} catch (error) {
4440
output.debug(`Error while parsing repo data: ${error.message}`);
4541
}
42+
}
43+
44+
export function pluckRemoteUrl(gitConfig: {
45+
[key: string]: any;
46+
}): string | undefined {
47+
// Assuming "origin" is the remote url that the user would want to use
48+
return gitConfig['remote "origin"']?.url;
49+
}
50+
51+
export async function getRemoteUrl(
52+
configPath: string,
53+
output: Output
54+
): Promise<string | null> {
55+
let gitConfig = await parseGitConfig(configPath, output);
4656
if (!gitConfig) {
4757
return null;
4858
}
4959

50-
const originUrl: string = gitConfig['remote "origin"']?.url;
60+
const originUrl = pluckRemoteUrl(gitConfig);
5161
if (originUrl) {
5262
return originUrl;
5363
}

packages/cli/src/util/input/select-org.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import inquirer from 'inquirer';
21
import Client from '../client';
32
import getUser from '../get-user';
43
import getTeams from '../teams/get-teams';
@@ -43,7 +42,7 @@ export default async function selectOrg(
4342
return choices[defaultOrgIndex].value;
4443
}
4544

46-
const answers = await inquirer.prompt({
45+
const answers = await client.prompt({
4746
type: 'list',
4847
name: 'org',
4948
message: question,

0 commit comments

Comments
 (0)