feat(nx-cloud): new cloud onboarding flow (#26262)

New Nx cloud onboarding flow.

![Screenshot 2024-05-30 at 5 29
43 PM](https://github.com/nrwl/nx/assets/6603745/c26f6416-f54b-4b6d-8b3c-4d4c1acfcbb1)

![Screenshot 2024-05-30 at 5 30
18 PM](https://github.com/nrwl/nx/assets/6603745/acb2b1d1-8437-4d8b-8b87-602cc918f12b)

![Screenshot 2024-05-30 at 5 31
26 PM](https://github.com/nrwl/nx/assets/6603745/e6b1f595-e3c1-4c09-83e7-9f71b5383a35)
This commit is contained in:
Katerina Skroumpelou 2024-06-05 11:45:22 +03:00 committed by GitHub
parent 260562e484
commit f8239debd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 442 additions and 60 deletions

View File

@ -183,6 +183,14 @@ Type: `string`
Stylesheet type to be used with certain stacks
### useGitHub
Type: `boolean`
Default: `false`
Will you be using GitHub as your git hosting provider?
### version
Type: `boolean`

View File

@ -183,6 +183,14 @@ Type: `string`
Stylesheet type to be used with certain stacks
### useGitHub
Type: `boolean`
Default: `false`
Will you be using GitHub as your git hosting provider?
### version
Type: `boolean`

View File

@ -23,6 +23,11 @@
"type": "boolean",
"description": "Hide formatting logs",
"x-priority": "internal"
},
"github": {
"type": "boolean",
"description": "If the user will be using GitHub as their git hosting provider",
"default": false
}
},
"additionalProperties": false,

View File

@ -13,12 +13,14 @@ import { yargsDecorator } from './decorator';
import { getPackageNameFromThirdPartyPreset } from '../src/utils/preset/get-third-party-preset';
import {
determineDefaultBase,
determineIfGitHubWillBeUsed,
determineNxCloud,
determinePackageManager,
} from '../src/internal-utils/prompts';
import {
withAllPrompts,
withGitOptions,
withUseGitHub,
withNxCloud,
withOptions,
withPackageManager,
@ -183,6 +185,7 @@ export const commandsObject: yargs.Argv<Arguments> = yargs
type: 'string',
}),
withNxCloud,
withUseGitHub,
withAllPrompts,
withPackageManager,
withGitOptions
@ -280,13 +283,28 @@ async function normalizeArgsMiddleware(
const packageManager = await determinePackageManager(argv);
const defaultBase = await determineDefaultBase(argv);
const nxCloud = await determineNxCloud(argv);
Object.assign(argv, {
nxCloud,
packageManager,
defaultBase,
});
if (process.env.NX_NEW_CLOUD_ONBOARDING === 'true') {
const nxCloud =
argv.skipGit === true ? 'skip' : await determineNxCloud(argv);
const useGitHub =
nxCloud === 'skip'
? undefined
: nxCloud === 'github' ||
(await determineIfGitHubWillBeUsed(nxCloud));
Object.assign(argv, {
nxCloud,
useGitHub,
packageManager,
defaultBase,
});
} else {
const nxCloud = await determineNxCloud(argv);
Object.assign(argv, {
nxCloud,
packageManager,
defaultBase,
});
}
} catch (e) {
console.error(e);
process.exit(1);

View File

@ -5,6 +5,7 @@ export interface CreateWorkspaceOptions {
name: string; // Workspace name (e.g. org name)
packageManager: PackageManager; // Package manager to use
nxCloud: NxCloud; // Enable Nx Cloud
useGitHub?: boolean; // Will you be using GitHub as your git hosting provider?
/**
* @description Enable interactive mode with presets
* @default true

View File

@ -21,6 +21,7 @@ export async function createWorkspace<T extends CreateWorkspaceOptions>(
defaultBase = 'main',
commit,
cliName,
useGitHub,
} = options;
if (cliName) {
@ -52,7 +53,12 @@ export async function createWorkspace<T extends CreateWorkspaceOptions>(
let nxCloudInstallRes;
if (nxCloud !== 'skip') {
nxCloudInstallRes = await setupNxCloud(directory, packageManager, nxCloud);
nxCloudInstallRes = await setupNxCloud(
directory,
packageManager,
nxCloud,
useGitHub
);
if (nxCloud !== 'yes') {
await setupCI(

View File

@ -22,6 +22,24 @@ export async function determineNxCloud(
}
}
export async function determineIfGitHubWillBeUsed(
nxCloud: NxCloud
): Promise<boolean> {
if (nxCloud === 'yes' || nxCloud === 'circleci') {
const reply = await enquirer.prompt<{ github: 'Yes' | 'No' }>([
{
name: 'github',
message: 'Will you be using GitHub as your git hosting provider?',
type: 'autocomplete',
choices: [{ name: 'Yes' }, { name: 'No' }],
initial: 0,
},
]);
return reply.github === 'Yes';
}
return false;
}
async function nxCloudPrompt(key: MessageKey): Promise<NxCloud> {
const { message, choices, initial, fallback, footer, hint } =
messages.getPrompt(key);

View File

@ -15,6 +15,14 @@ export function withNxCloud<T = unknown>(argv: yargs.Argv<T>) {
return result;
}
export function withUseGitHub<T = unknown>(argv: yargs.Argv<T>) {
return argv.option('useGitHub', {
describe: chalk.dim`Will you be using GitHub as your git hosting provider?`,
type: 'boolean',
default: false,
});
}
export function withAllPrompts<T = unknown>(argv: yargs.Argv<T>) {
return argv.option('allPrompts', {
alias: 'a',

View File

@ -9,15 +9,23 @@ export type NxCloud = 'yes' | 'github' | 'circleci' | 'skip';
export async function setupNxCloud(
directory: string,
packageManager: PackageManager,
nxCloud: NxCloud
nxCloud: NxCloud,
useGitHub?: boolean
) {
const nxCloudSpinner = ora(`Setting up Nx Cloud`).start();
try {
const pmc = getPackageManagerCommand(packageManager);
const res = await execAndWait(
`${pmc.exec} nx g nx:connect-to-nx-cloud --no-interactive --quiet`,
process.env.NX_NEW_CLOUD_ONBOARDING === 'true'
? `${
pmc.exec
} nx g nx:connect-to-nx-cloud --installationSource=create-nx-workspace ${
useGitHub ? '--github' : ''
} --no-interactive`
: `${pmc.exec} nx g nx:connect-to-nx-cloud --no-interactive --quiet`,
directory
);
if (nxCloud !== 'yes') {
nxCloudSpinner.succeed(
'CI workflow with Nx Cloud has been generated successfully'

View File

@ -2,6 +2,7 @@ import { output } from '../../utils/output';
import { readNxJson } from '../../config/configuration';
import { FsTree, flushChanges } from '../../generators/tree';
import { connectToNxCloud } from '../../nx-cloud/generators/connect-to-nx-cloud/connect-to-nx-cloud';
import { shortenedCloudUrl } from '../../nx-cloud/utilities/url-shorten';
import { getNxCloudUrl, isNxCloudUsed } from '../../utils/nx-cloud-utils';
import { runNxSync } from '../../utils/child-process';
import { NxJsonConfiguration } from '../../config/nx-json';
@ -51,14 +52,35 @@ export async function connectToNxCloudIfExplicitlyAsked(
export async function connectToNxCloudCommand(): Promise<boolean> {
const nxJson = readNxJson();
if (isNxCloudUsed(nxJson)) {
output.log({
title: '✔ This workspace already has Nx Cloud set up',
bodyLines: [
'If you have not done so already, connect your workspace to your Nx Cloud account:',
`- Login at ${getNxCloudUrl(nxJson)} to connect your repository`,
],
});
if (process.env.NX_NEW_CLOUD_ONBOARDING !== 'true') {
output.log({
title: '✔ This workspace already has Nx Cloud set up',
bodyLines: [
'If you have not done so already, connect your workspace to your Nx Cloud account:',
`- Login at ${getNxCloudUrl(nxJson)} to connect your repository`,
],
});
} else {
const token =
process.env.NX_CLOUD_ACCESS_TOKEN || nxJson.nxCloudAccessToken;
if (!token) {
throw new Error(
`Unable to authenticate. Either define accessToken in nx.json or set the NX_CLOUD_ACCESS_TOKEN env variable.`
);
}
const connectCloudUrl = await shortenedCloudUrl('nx-connect', token);
output.log({
title: '✔ This workspace already has Nx Cloud set up',
bodyLines: [
'If you have not done so already, connect your workspace to your Nx Cloud account:',
`- Connect with Nx Cloud at:
${connectCloudUrl}`,
],
});
}
return false;
}
@ -66,7 +88,7 @@ export async function connectToNxCloudCommand(): Promise<boolean> {
const callback = await connectToNxCloud(tree, {});
tree.lock();
flushChanges(workspaceRoot, tree.listChanges());
callback();
await callback();
return true;
}

View File

@ -5,7 +5,7 @@ import { output } from '../../utils/output';
import { getPackageManagerCommand } from '../../utils/package-manager';
import { generateDotNxSetup } from './implementation/dot-nx/add-nx-scripts';
import { runNxSync } from '../../utils/child-process';
import { readJsonFile, writeJsonFile } from '../../utils/fileutils';
import { readJsonFile } from '../../utils/fileutils';
import { nxVersion } from '../../utils/versions';
import {
addDepsToPackageJson,
@ -22,7 +22,6 @@ import { globWithWorkspaceContext } from '../../utils/workspace-context';
import { connectExistingRepoToNxCloudPrompt } from '../connect/connect-to-nx-cloud';
import { addNxToNpmRepo } from './implementation/add-nx-to-npm-repo';
import { addNxToMonorepo } from './implementation/add-nx-to-monorepo';
import { join } from 'path';
export interface InitArgs {
interactive: boolean;

View File

@ -32,6 +32,7 @@ import {
writeJsonFile,
} from '../../utils/fileutils';
import { logger } from '../../utils/logger';
import { commitChanges } from '../../utils/git-utils';
import {
ArrayPackageGroup,
NxMigrationsConfiguration,
@ -1573,32 +1574,6 @@ function getStringifiedPackageJsonDeps(root: string): string {
}
}
function commitChanges(commitMessage: string): string | null {
try {
execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' });
execSync('git commit --no-verify -F -', {
encoding: 'utf8',
stdio: 'pipe',
input: commitMessage,
});
} catch (err) {
throw new Error(`Error committing changes:\n${err.stderr}`);
}
return getLatestCommitSha();
}
function getLatestCommitSha(): string | null {
try {
return execSync('git rev-parse HEAD', {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
} catch {
return null;
}
}
async function runNxMigration(
root: string,
collectionPath: string,

View File

@ -6,6 +6,7 @@ import { readJson } from '../../../generators/utils/json';
import { NxJsonConfiguration } from '../../../config/nx-json';
import { readNxJson, updateNxJson } from '../../../generators/utils/nx-json';
import { formatChangedFilesWithPrettierIfAvailable } from '../../../generators/internal-utils/format-changed-files-with-prettier-if-available';
import { shortenedCloudUrl } from '../../utilities/url-shorten';
function printCloudConnectionDisabledMessage() {
output.error({
@ -72,26 +73,52 @@ async function createNxCloudWorkspace(
return response.data;
}
function printSuccessMessage(url: string) {
let origin = 'https://nx.app';
try {
origin = new URL(url).origin;
} catch (e) {}
async function printSuccessMessage(
url: string,
token: string,
installationSource: string,
github: boolean
) {
if (process.env.NX_NEW_CLOUD_ONBOARDING !== 'true') {
let origin = 'https://nx.app';
try {
origin = new URL(url).origin;
} catch (e) {}
output.note({
title: `Your Nx Cloud workspace is public`,
bodyLines: [
`To restrict access, connect it to your Nx Cloud account:`,
`- Push your changes`,
`- Login at ${origin} to connect your repository`,
],
});
output.note({
title: `Your Nx Cloud workspace is public`,
bodyLines: [
`To restrict access, connect it to your Nx Cloud account:`,
`- Push your changes`,
`- Login at ${origin} to connect your repository`,
],
});
} else {
const connectCloudUrl = await shortenedCloudUrl(
installationSource,
token,
github
);
output.note({
title: `Your Nx Cloud workspace is ready.`,
bodyLines: [
`To claim it, connect it to your Nx Cloud account:`,
`- Commit and push your changes.`,
`- Create a pull request for the changes.`,
`- Go to the following URL to connect your workspace to Nx Cloud:
${connectCloudUrl}`,
],
});
}
}
interface ConnectToNxCloudOptions {
analytics?: boolean;
installationSource?: string;
hideFormatLogs?: boolean;
github?: boolean;
}
function addNxCloudOptionsToNxJson(
@ -138,7 +165,13 @@ export async function connectToNxCloud(
silent: schema.hideFormatLogs,
});
return () => printSuccessMessage(r.url);
return async () =>
await printSuccessMessage(
r.url,
r.token,
schema.installationSource,
schema.github
);
}
}

View File

@ -20,6 +20,11 @@
"type": "boolean",
"description": "Hide formatting logs",
"x-priority": "internal"
},
"github": {
"type": "boolean",
"description": "If the user will be using GitHub as their git hosting provider",
"default": false
}
},
"additionalProperties": false,

View File

@ -0,0 +1,111 @@
import { logger } from '../../devkit-exports';
import { getGithubSlugOrNull } from '../../utils/git-utils';
export async function shortenedCloudUrl(
installationSource: string,
accessToken: string,
github?: boolean
) {
const githubSlug = getGithubSlugOrNull();
const apiUrl = removeTrailingSlash(
process.env.NX_CLOUD_API || process.env.NRWL_API || `https://cloud.nx.app`
);
const installationSupportsGitHub = await getInstallationSupportsGitHub(
apiUrl
);
const usesGithub =
(githubSlug || github) &&
(apiUrl.includes('cloud.nx.app') ||
apiUrl.includes('eu.nx.app') ||
installationSupportsGitHub);
const source = getSource(installationSource);
try {
const response = await require('axios').post(
`${apiUrl}/nx-cloud/onboarding`,
{
type: usesGithub ? 'GITHUB' : 'MANUAL',
source,
accessToken: usesGithub ? null : accessToken,
selectedRepositoryName: githubSlug,
}
);
if (!response?.data || response.data.message) {
throw new Error(
response?.data?.message ?? 'Failed to shorten Nx Cloud URL'
);
}
return `${apiUrl}/connect/${response.data}`;
} catch (e) {
logger.verbose(`Failed to shorten Nx Cloud URL.
${e}`);
return getURLifShortenFailed(
usesGithub,
githubSlug,
apiUrl,
accessToken,
source
);
}
}
function removeTrailingSlash(apiUrl: string) {
return apiUrl[apiUrl.length - 1] === '/' ? apiUrl.slice(0, -1) : apiUrl;
}
function getSource(
installationSource: string
): 'nx-init' | 'nx-connect' | 'create-nx-workspace' | 'other' {
if (installationSource.includes('nx-init')) {
return 'nx-init';
} else if (installationSource.includes('nx-connect')) {
return 'nx-connect';
} else if (installationSource.includes('create-nx-workspace')) {
return 'create-nx-workspace';
} else {
return 'other';
}
}
function getURLifShortenFailed(
usesGithub: boolean,
githubSlug: string,
apiUrl: string,
accessToken: string,
source: string
) {
if (usesGithub) {
if (githubSlug) {
return `${apiUrl}/setup/connect-workspace/vcs?provider=GITHUB&selectedRepositoryName=${encodeURIComponent(
githubSlug
)}&source=${source}`;
} else {
return `${apiUrl}/setup/connect-workspace/vcs?provider=GITHUB&source=${source}`;
}
}
return `${apiUrl}/setup/connect-workspace/manual?accessToken=${accessToken}&source=${source}`;
}
async function getInstallationSupportsGitHub(apiUrl: string): Promise<boolean> {
try {
const response = await require('axios').get(`${apiUrl}/vcs-integrations`);
if (!response?.data || response.data.message) {
throw new Error(
response?.data?.message ?? 'Failed to shorten Nx Cloud URL'
);
}
return !!response.data.github;
} catch (e) {
if (process.env.NX_VERBOSE_LOGGING) {
logger.warn(`Failed to access vcs-integrations endpoint.
${e}`);
}
return false;
}
}

View File

@ -0,0 +1,84 @@
import { extractUserAndRepoFromGitHubUrl } from './git-utils';
describe('extractUserAndRepoFromGitHubUrl', () => {
describe('ssh cases', () => {
it('should return the github user + repo info for origin', () => {
expect(
extractUserAndRepoFromGitHubUrl(
`
upstream git@github.com:upstream-user/repo-name.git (fetch)
upstream git@github.com:upstream-user/repo-name.git (push)
origin git@github.com:origin-user/repo-name.git (fetch)
origin git@github.com:origin-user/repo-name.git (push)
`
)
).toBe('origin-user/repo-name');
});
it('should return the github user + repo info for the first one since no origin', () => {
expect(
extractUserAndRepoFromGitHubUrl(
`
upstream git@github.com:upstream-user/repo-name.git (fetch)
upstream git@github.com:upstream-user/repo-name.git (push)
other git@github.com:other-user/repo-name.git (fetch)
other git@github.com:other-user/repo-name.git (push)
`
)
).toBe('upstream-user/repo-name');
});
it('should return null since no github', () => {
expect(
extractUserAndRepoFromGitHubUrl(
`
upstream git@random.com:upstream-user/repo-name.git (fetch)
upstream git@random.com:upstream-user/repo-name.git (push)
origin git@random.com:other-user/repo-name.git (fetch)
origin git@random.com:other-user/repo-name.git (push)
`
)
).toBe(null);
});
});
describe('https cases', () => {
it('should return the github user + repo info for origin', () => {
expect(
extractUserAndRepoFromGitHubUrl(
`
upstream https://github.com/upstream-user/repo-name.git (fetch)
upstream https://github.com/upstream-user/repo-name.git (push)
origin https://github.com/origin-user/repo-name.git (fetch)
origin https://github.com/origin-user/repo-name.git (push)
`
)
).toBe('origin-user/repo-name');
});
it('should return the github user + repo info for the first one since no origin', () => {
expect(
extractUserAndRepoFromGitHubUrl(
`
upstream https://github.com/upstream-user/repo-name.git (fetch)
upstream https://github.com/upstream-user/repo-name.git (push)
other https://github.com/other-user/repo-name.git (fetch)
other https://github.com/other-user/repo-name.git (push)
`
)
).toBe('upstream-user/repo-name');
});
it('should return null since no github', () => {
expect(
extractUserAndRepoFromGitHubUrl(
`
upstream https://other.com/upstream-user/repo-name.git (fetch)
upstream https://other.com/upstream-user/repo-name.git (push)
origin https://other.com/other-user/repo-name.git (fetch)
origin https://other.com/other-user/repo-name.git (push)
`
)
).toBe(null);
});
});
});

View File

@ -0,0 +1,73 @@
import { execSync } from 'child_process';
export function getGithubSlugOrNull(): string | null {
try {
const gitRemote = execSync('git remote -v').toString();
return extractUserAndRepoFromGitHubUrl(gitRemote);
} catch (e) {
return null;
}
}
export function extractUserAndRepoFromGitHubUrl(
gitRemotes: string
): string | null {
const regex =
/^\s*(\w+)\s+(git@github\.com:|https:\/\/github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\.git/gm;
let firstGitHubUrl: string | null = null;
let match;
while ((match = regex.exec(gitRemotes)) !== null) {
const remoteName = match[1];
const url = match[2] + match[3] + '/' + match[4] + '.git';
if (remoteName === 'origin') {
return parseGitHubUrl(url);
}
if (!firstGitHubUrl) {
firstGitHubUrl = url;
}
}
return firstGitHubUrl ? parseGitHubUrl(firstGitHubUrl) : null;
}
function parseGitHubUrl(url: string): string | null {
const sshPattern =
/git@github\.com:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\.git/;
const httpsPattern =
/https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\.git/;
let match = url.match(sshPattern) || url.match(httpsPattern);
if (match) {
return `${match[1]}/${match[2]}`;
}
return null;
}
export function commitChanges(commitMessage: string): string | null {
try {
execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' });
execSync('git commit --no-verify -F -', {
encoding: 'utf8',
stdio: 'pipe',
input: commitMessage,
});
} catch (err) {
throw new Error(`Error committing changes:\n${err.stderr}`);
}
return getLatestCommitSha();
}
export function getLatestCommitSha(): string | null {
try {
return execSync('git rev-parse HEAD', {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
} catch {
return null;
}
}