mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-10 12:57:47 +00:00
[cli] Run local Project detection during vc link --repo (#10094)
Run local Project detection during `vc link --repo`. This allows for creation of new Projects that do not yet exist under the selected scope.
This commit is contained in:
6
.changeset/popular-dots-nail.md
Normal file
6
.changeset/popular-dots-nail.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'vercel': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Run local Project detection during `vc link --repo`.
|
||||||
|
This allows for creation of new Projects that do not yet exist under the selected scope.
|
||||||
@@ -97,7 +97,7 @@ export default async function main(client: Client) {
|
|||||||
client.output.warn(
|
client.output.warn(
|
||||||
`The ${cmd('--repo')} flag is in alpha, please report issues`
|
`The ${cmd('--repo')} flag is in alpha, please report issues`
|
||||||
);
|
);
|
||||||
await ensureRepoLink(client, cwd, yes);
|
await ensureRepoLink(client, cwd, { yes, overwrite: true });
|
||||||
} else {
|
} else {
|
||||||
const link = await ensureLink('link', client, cwd, {
|
const link = await ensureLink('link', client, cwd, {
|
||||||
autoConfirm: yes,
|
autoConfirm: yes,
|
||||||
|
|||||||
5
packages/cli/src/util/git/repo-info-to-url.ts
Normal file
5
packages/cli/src/util/git/repo-info-to-url.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { RepoInfo } from './connect-git-provider';
|
||||||
|
|
||||||
|
export function repoInfoToUrl(info: RepoInfo): string {
|
||||||
|
return `https://${info.provider}.com/${info.org}/${info.repo}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
import pluralize from 'pluralize';
|
import pluralize from 'pluralize';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { join, normalize } from 'path';
|
import slugify from '@sindresorhus/slugify';
|
||||||
|
import { basename, join, normalize } from 'path';
|
||||||
import { normalizePath, traverseUpDirectories } from '@vercel/build-utils';
|
import { normalizePath, traverseUpDirectories } from '@vercel/build-utils';
|
||||||
import { lstat, readJSON, outputJSON } from 'fs-extra';
|
import { lstat, readJSON, outputJSON } from 'fs-extra';
|
||||||
import confirm from '../input/confirm';
|
import confirm from '../input/confirm';
|
||||||
@@ -14,6 +16,10 @@ import selectOrg from '../input/select-org';
|
|||||||
import { addToGitIgnore } from './add-to-gitignore';
|
import { addToGitIgnore } from './add-to-gitignore';
|
||||||
import type Client from '../client';
|
import type Client from '../client';
|
||||||
import type { Project } from '@vercel-internals/types';
|
import type { Project } from '@vercel-internals/types';
|
||||||
|
import createProject from '../projects/create-project';
|
||||||
|
import { detectProjects } from '../projects/detect-projects';
|
||||||
|
import { repoInfoToUrl } from '../git/repo-info-to-url';
|
||||||
|
import { connectGitProvider, parseRepoUrl } from '../git/connect-git-provider';
|
||||||
|
|
||||||
const home = homedir();
|
const home = homedir();
|
||||||
|
|
||||||
@@ -35,6 +41,11 @@ export interface RepoLink {
|
|||||||
repoConfig?: RepoProjectsConfig;
|
repoConfig?: RepoProjectsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnsureRepoLinkOptions {
|
||||||
|
yes: boolean;
|
||||||
|
overwrite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a directory path `cwd`, finds the root of the Git repository
|
* Given a directory path `cwd`, finds the root of the Git repository
|
||||||
* and returns the parsed `.vercel/repo.json` file if the repository
|
* and returns the parsed `.vercel/repo.json` file if the repository
|
||||||
@@ -62,7 +73,7 @@ export async function getRepoLink(
|
|||||||
export async function ensureRepoLink(
|
export async function ensureRepoLink(
|
||||||
client: Client,
|
client: Client,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
yes = false
|
{ yes, overwrite }: EnsureRepoLinkOptions
|
||||||
): Promise<RepoLink | undefined> {
|
): Promise<RepoLink | undefined> {
|
||||||
const { output } = client;
|
const { output } = client;
|
||||||
|
|
||||||
@@ -74,7 +85,14 @@ export async function ensureRepoLink(
|
|||||||
}
|
}
|
||||||
let { rootPath, repoConfig, repoConfigPath } = repoLink;
|
let { rootPath, repoConfig, repoConfigPath } = repoLink;
|
||||||
|
|
||||||
if (!repoConfig) {
|
if (overwrite || !repoConfig) {
|
||||||
|
// Detect the projects on the filesystem out of band, so that
|
||||||
|
// they will be ready by the time the projects are listed
|
||||||
|
const detectedProjectsPromise = detectProjects(rootPath).catch(err => {
|
||||||
|
output.debug(`Failed to detect local projects: ${err}`);
|
||||||
|
return new Map<string, string>();
|
||||||
|
});
|
||||||
|
|
||||||
// Not yet linked, so prompt user to begin linking
|
// Not yet linked, so prompt user to begin linking
|
||||||
let shouldLink =
|
let shouldLink =
|
||||||
yes ||
|
yes ||
|
||||||
@@ -111,41 +129,36 @@ export async function ensureRepoLink(
|
|||||||
remoteName = remoteNames[0];
|
remoteName = remoteNames[0];
|
||||||
} else {
|
} else {
|
||||||
// Prompt user to select which remote to use
|
// Prompt user to select which remote to use
|
||||||
const originIndex = remoteNames.indexOf('origin');
|
|
||||||
const answer = await client.prompt({
|
const answer = await client.prompt({
|
||||||
type: 'list',
|
type: 'list',
|
||||||
name: 'value',
|
name: 'value',
|
||||||
message: 'Which Git remote should be used?',
|
message: 'Which Git remote should be used?',
|
||||||
choices: remoteNames.map(name => {
|
choices: remoteNames.sort().map(name => {
|
||||||
return { name: name, value: name };
|
return { name: name, value: name };
|
||||||
}),
|
}),
|
||||||
default: originIndex === -1 ? 0 : originIndex,
|
default: remoteNames.includes('origin') ? 'origin' : undefined,
|
||||||
});
|
});
|
||||||
remoteName = answer.value;
|
remoteName = answer.value;
|
||||||
}
|
}
|
||||||
const repoUrl = remoteUrls[remoteName];
|
const repoUrl = remoteUrls[remoteName];
|
||||||
|
const parsedRepoUrl = parseRepoUrl(repoUrl);
|
||||||
|
if (!parsedRepoUrl) {
|
||||||
|
throw new Error(`Failed to parse Git URL: ${repoUrl}`);
|
||||||
|
}
|
||||||
|
const repoUrlLink = output.link(repoUrl, repoInfoToUrl(parsedRepoUrl), {
|
||||||
|
fallback: () => link(repoUrl),
|
||||||
|
});
|
||||||
output.spinner(
|
output.spinner(
|
||||||
`Fetching Projects for ${link(repoUrl)} under ${chalk.bold(org.slug)}…`
|
`Fetching Projects for ${repoUrlLink} under ${chalk.bold(org.slug)}…`
|
||||||
);
|
);
|
||||||
let projects: Project[] = [];
|
let projects: Project[] = [];
|
||||||
const query = new URLSearchParams({ repoUrl });
|
const query = new URLSearchParams({ repoUrl });
|
||||||
const projectsIterator = client.fetchPaginated<{
|
const projectsIterator = client.fetchPaginated<{
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
}>(`/v9/projects?${query}`);
|
}>(`/v9/projects?${query}`);
|
||||||
let printedFound = false;
|
const detectedProjects = await detectedProjectsPromise;
|
||||||
for await (const chunk of projectsIterator) {
|
for await (const chunk of projectsIterator) {
|
||||||
projects = projects.concat(chunk.projects);
|
projects = projects.concat(chunk.projects);
|
||||||
if (!printedFound && projects.length > 0) {
|
|
||||||
output.log(
|
|
||||||
`${pluralize('Project', chunk.projects.length)} linked to ${link(
|
|
||||||
repoUrl
|
|
||||||
)} under ${chalk.bold(org.slug)}:`
|
|
||||||
);
|
|
||||||
printedFound = true;
|
|
||||||
}
|
|
||||||
for (const project of chunk.projects) {
|
|
||||||
output.print(` * ${chalk.cyan(`${org.slug}/${project.name}\n`)}`);
|
|
||||||
}
|
|
||||||
if (chunk.pagination.next) {
|
if (chunk.pagination.next) {
|
||||||
output.spinner(`Found ${chalk.bold(projects.length)} Projects…`, 0);
|
output.spinner(`Found ${chalk.bold(projects.length)} Projects…`, 0);
|
||||||
}
|
}
|
||||||
@@ -153,36 +166,111 @@ export async function ensureRepoLink(
|
|||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
output.log(
|
output.log(
|
||||||
`No Projects are linked to ${link(repoUrl)} under ${chalk.bold(
|
`No Projects are linked to ${repoUrlLink} under ${chalk.bold(
|
||||||
org.slug
|
org.slug
|
||||||
)}.`
|
)}.`
|
||||||
);
|
);
|
||||||
// TODO: run detection logic to find potential projects.
|
} else {
|
||||||
// then prompt user to select valid projects.
|
output.log(
|
||||||
// then create new Projects
|
`Found ${pluralize(
|
||||||
|
'Project',
|
||||||
|
projects.length,
|
||||||
|
true
|
||||||
|
)} linked to ${repoUrlLink} under ${chalk.bold(org.slug)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldLink =
|
// For any projects that already exists on Vercel, remove them from the
|
||||||
yes ||
|
// locally detected directories. Any remaining ones will be prompted to
|
||||||
(await confirm(
|
// create new Projects for.
|
||||||
client,
|
for (const project of projects) {
|
||||||
`Link to ${
|
detectedProjects.delete(project.rootDirectory ?? '');
|
||||||
projects.length === 1
|
}
|
||||||
? 'this Project'
|
|
||||||
: `these ${chalk.bold(projects.length)} Projects`
|
|
||||||
}?`,
|
|
||||||
true
|
|
||||||
));
|
|
||||||
|
|
||||||
if (!shouldLink) {
|
if (detectedProjects.size > 0) {
|
||||||
output.print(`Canceled. Repository not linked.\n`);
|
output.log(
|
||||||
|
`Detected ${pluralize(
|
||||||
|
'new Project',
|
||||||
|
detectedProjects.size,
|
||||||
|
true
|
||||||
|
)} that may be created.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSeparators = projects.length > 0 && detectedProjects.size > 0;
|
||||||
|
const { selected } = await client.prompt({
|
||||||
|
type: 'checkbox',
|
||||||
|
name: 'selected',
|
||||||
|
message: `Which Projects should be ${
|
||||||
|
projects.length ? 'linked to' : 'created'
|
||||||
|
}?`,
|
||||||
|
choices: [
|
||||||
|
...(addSeparators
|
||||||
|
? [new inquirer.Separator('----- Existing Projects -----')]
|
||||||
|
: []),
|
||||||
|
...projects.map(project => {
|
||||||
|
return {
|
||||||
|
name: `${org.slug}/${project.name}`,
|
||||||
|
value: project,
|
||||||
|
checked: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...(addSeparators
|
||||||
|
? [new inquirer.Separator('----- New Projects to be created -----')]
|
||||||
|
: []),
|
||||||
|
...Array.from(detectedProjects.entries()).map(
|
||||||
|
([rootDirectory, framework]) => {
|
||||||
|
const name = slugify(
|
||||||
|
[basename(rootPath), basename(rootDirectory)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('-')
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: `${org.slug}/${name} (${framework})`,
|
||||||
|
value: {
|
||||||
|
newProject: true,
|
||||||
|
rootDirectory,
|
||||||
|
name,
|
||||||
|
framework,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
|
output.print(`No Projects were selected. Repository not linked.\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < selected.length; i++) {
|
||||||
|
const selection = selected[i];
|
||||||
|
if (!selection.newProject) continue;
|
||||||
|
const orgAndName = `${org.slug}/${selection.name}`;
|
||||||
|
output.spinner(`Creating new Project: ${orgAndName}`);
|
||||||
|
delete selection.newProject;
|
||||||
|
if (!selection.rootDirectory) delete selection.rootDirectory;
|
||||||
|
selected[i] = await createProject(client, selection);
|
||||||
|
await connectGitProvider(
|
||||||
|
client,
|
||||||
|
org,
|
||||||
|
selected[i].id,
|
||||||
|
parsedRepoUrl.provider,
|
||||||
|
`${parsedRepoUrl.org}/${parsedRepoUrl.repo}`
|
||||||
|
);
|
||||||
|
output.log(
|
||||||
|
`Created new Project: ${output.link(
|
||||||
|
orgAndName,
|
||||||
|
`https://vercel.com/${orgAndName}`
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
repoConfig = {
|
repoConfig = {
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
remoteName,
|
remoteName,
|
||||||
projects: projects.map(project => {
|
projects: selected.map((project: Project) => {
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@@ -199,7 +287,7 @@ export async function ensureRepoLink(
|
|||||||
|
|
||||||
output.print(
|
output.print(
|
||||||
prependEmoji(
|
prependEmoji(
|
||||||
`Linked to ${link(repoUrl)} under ${chalk.bold(
|
`Linked to ${repoUrlLink} under ${chalk.bold(
|
||||||
org.slug
|
org.slug
|
||||||
)} (created ${VERCEL_DIR}${
|
)} (created ${VERCEL_DIR}${
|
||||||
isGitIgnoreUpdated ? ' and added it to .gitignore' : ''
|
isGitIgnoreUpdated ? ' and added it to .gitignore' : ''
|
||||||
|
|||||||
38
packages/cli/src/util/projects/detect-projects.ts
Normal file
38
packages/cli/src/util/projects/detect-projects.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import frameworks from '@vercel/frameworks';
|
||||||
|
import {
|
||||||
|
detectFramework,
|
||||||
|
getWorkspacePackagePaths,
|
||||||
|
getWorkspaces,
|
||||||
|
LocalFileSystemDetector,
|
||||||
|
} from '@vercel/fs-detectors';
|
||||||
|
|
||||||
|
export async function detectProjects(cwd: string) {
|
||||||
|
const fs = new LocalFileSystemDetector(cwd);
|
||||||
|
const workspaces = await getWorkspaces({ fs });
|
||||||
|
const detectedProjects = new Map<string, string>();
|
||||||
|
const packagePaths = (
|
||||||
|
await Promise.all(
|
||||||
|
workspaces.map(workspace =>
|
||||||
|
getWorkspacePackagePaths({
|
||||||
|
fs,
|
||||||
|
workspace,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
if (packagePaths.length === 0) {
|
||||||
|
packagePaths.push('/');
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
packagePaths.map(async p => {
|
||||||
|
const framework = await detectFramework({
|
||||||
|
fs: fs.chdir(join('.', p)),
|
||||||
|
frameworkList: frameworks,
|
||||||
|
});
|
||||||
|
if (!framework) return;
|
||||||
|
detectedProjects.set(p.slice(1), framework);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return detectedProjects;
|
||||||
|
}
|
||||||
34
packages/cli/test/unit/util/git/repo-info-to-url.test.ts
Normal file
34
packages/cli/test/unit/util/git/repo-info-to-url.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { repoInfoToUrl } from '../../../../src/util/git/repo-info-to-url';
|
||||||
|
import type { RepoInfo } from '../../../../src/util/git/connect-git-provider';
|
||||||
|
|
||||||
|
describe('repoInfoToUrl()', () => {
|
||||||
|
it('should support "github" URL', () => {
|
||||||
|
const info: RepoInfo = {
|
||||||
|
provider: 'github',
|
||||||
|
org: 'vercel',
|
||||||
|
repo: 'foo',
|
||||||
|
url: 'git@github.com:vercel/foo.git',
|
||||||
|
};
|
||||||
|
expect(repoInfoToUrl(info)).toEqual('https://github.com/vercel/foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support "gitlab" URL', () => {
|
||||||
|
const info: RepoInfo = {
|
||||||
|
provider: 'gitlab',
|
||||||
|
org: 'vercel',
|
||||||
|
repo: 'foo',
|
||||||
|
url: 'git@gitlab.com:vercel/foo.git',
|
||||||
|
};
|
||||||
|
expect(repoInfoToUrl(info)).toEqual('https://gitlab.com/vercel/foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support "bitbucket" URL', () => {
|
||||||
|
const info: RepoInfo = {
|
||||||
|
provider: 'bitbucket',
|
||||||
|
org: 'vercel',
|
||||||
|
repo: 'foo',
|
||||||
|
url: 'git@bitbucket.com:vercel/foo.git',
|
||||||
|
};
|
||||||
|
expect(repoInfoToUrl(info)).toEqual('https://bitbucket.com/vercel/foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
30
packages/cli/test/unit/util/projects/detect-projects.test.ts
Normal file
30
packages/cli/test/unit/util/projects/detect-projects.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import { detectProjects } from '../../../../src/util/projects/detect-projects';
|
||||||
|
|
||||||
|
const REPO_ROOT = join(__dirname, '../../../../../..');
|
||||||
|
const EXAMPLES_DIR = join(REPO_ROOT, 'examples');
|
||||||
|
const FS_DETECTORS_FIXTURES = join(
|
||||||
|
REPO_ROOT,
|
||||||
|
'packages/fs-detectors/test/fixtures'
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('detectProjects()', () => {
|
||||||
|
it('should match "nextjs" example', async () => {
|
||||||
|
const dir = join(EXAMPLES_DIR, 'nextjs');
|
||||||
|
const detected = await detectProjects(dir);
|
||||||
|
expect([...detected.entries()]).toEqual([['', 'nextjs']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match "30-double-nested-workspaces"', async () => {
|
||||||
|
const dir = join(FS_DETECTORS_FIXTURES, '30-double-nested-workspaces');
|
||||||
|
const detected = await detectProjects(dir);
|
||||||
|
expect(
|
||||||
|
[...detected.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
).toEqual([
|
||||||
|
['packages/backend/c', 'remix'],
|
||||||
|
['packages/backend/d', 'nextjs'],
|
||||||
|
['packages/frontend/a', 'hexo'],
|
||||||
|
['packages/frontend/b', 'ember'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"private": true,
|
||||||
"name": "backend-c30",
|
"name": "backend-c30",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"private": true,
|
||||||
"name": "backend-d30",
|
"name": "backend-d30",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"next": "*",
|
||||||
"once": "1.4.0"
|
"once": "1.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"private": true,
|
||||||
"name": "frontend-a30",
|
"name": "frontend-a30",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.2"
|
"debug": "^4.3.2",
|
||||||
|
"hexo": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"private": true,
|
||||||
"name": "frontend-b30",
|
"name": "frontend-b30",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cowsay": "^1.5.0"
|
"cowsay": "^1.5.0",
|
||||||
|
"ember-cli": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user