mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 21:07:46 +00:00
[cli] New vc promote command (#9984)
~This PR is blocked by https://github.com/vercel/api/pull/19508.~ Linear: https://linear.app/vercel/issue/VCCLI-262/cli-new-command-to-promote-deployment
This commit is contained in:
@@ -24,6 +24,7 @@ export const help = () => `
|
||||
ls | list [app] Lists deployments
|
||||
login [email] Logs into your account or creates a new one
|
||||
logout Logs out of your account
|
||||
promote [url|id] Promote an existing deployment to current
|
||||
pull [path] Pull your Project Settings from the cloud
|
||||
redeploy [url|id] Rebuild and deploy a previous deployment.
|
||||
rollback [url|id] Quickly revert back to a previous deployment
|
||||
|
||||
@@ -26,6 +26,7 @@ export default new Map([
|
||||
['ls', 'list'],
|
||||
['project', 'project'],
|
||||
['projects', 'project'],
|
||||
['promote', 'promote'],
|
||||
['pull', 'pull'],
|
||||
['redeploy', 'redeploy'],
|
||||
['remove', 'remove'],
|
||||
|
||||
121
packages/cli/src/commands/promote/index.ts
Normal file
121
packages/cli/src/commands/promote/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import chalk from 'chalk';
|
||||
import type Client from '../../util/client';
|
||||
import getArgs from '../../util/get-args';
|
||||
import getProjectByCwdOrLink from '../../util/projects/get-project-by-cwd-or-link';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
import handleError from '../../util/handle-error';
|
||||
import { isErrnoException } from '@vercel/error-utils';
|
||||
import logo from '../../util/output/logo';
|
||||
import ms from 'ms';
|
||||
import requestPromote from './request-promote';
|
||||
import promoteStatus from './status';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} promote`)} [deployment id/url]
|
||||
|
||||
Promote an existing deployment to current.
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
--timeout=${chalk.bold.underline(
|
||||
'TIME'
|
||||
)} Time to wait for promotion completion [3m]
|
||||
-y, --yes Skip questions when setting up new project using default scope and settings
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Show the status of any current pending promotions
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} promote`)}
|
||||
${chalk.cyan(`$ ${getPkgName()} promote status`)}
|
||||
${chalk.cyan(`$ ${getPkgName()} promote status <project>`)}
|
||||
${chalk.cyan(`$ ${getPkgName()} promote status --timeout 30s`)}
|
||||
|
||||
${chalk.gray('–')} Promote a deployment using id or url
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} promote <deployment id/url>`)}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* `vc promote` command
|
||||
* @param {Client} client
|
||||
* @returns {Promise<number>} Resolves an exit code; 0 on success
|
||||
*/
|
||||
export default async (client: Client): Promise<number> => {
|
||||
let argv;
|
||||
try {
|
||||
argv = getArgs(client.argv.slice(2), {
|
||||
'--timeout': String,
|
||||
'--yes': Boolean,
|
||||
'-y': '--yes',
|
||||
});
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (argv['--help'] || argv._[0] === 'help') {
|
||||
help();
|
||||
return 2;
|
||||
}
|
||||
|
||||
// validate the timeout
|
||||
let timeout = argv['--timeout'];
|
||||
if (timeout && ms(timeout) === undefined) {
|
||||
client.output.error(`Invalid timeout "${timeout}"`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const actionOrDeployId = argv._[1] || 'status';
|
||||
|
||||
try {
|
||||
if (actionOrDeployId === 'status') {
|
||||
const project = await getProjectByCwdOrLink({
|
||||
autoConfirm: Boolean(argv['--yes']),
|
||||
client,
|
||||
commandName: 'promote',
|
||||
cwd: argv['--cwd'] || process.cwd(),
|
||||
projectNameOrId: argv._[2],
|
||||
});
|
||||
|
||||
return await promoteStatus({
|
||||
client,
|
||||
project,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
return await requestPromote({
|
||||
client,
|
||||
deployId: actionOrDeployId,
|
||||
timeout,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isErrnoException(err)) {
|
||||
if (err.code === 'ERR_CANCELED') {
|
||||
return 0;
|
||||
}
|
||||
if (err.code === 'ERR_INVALID_CWD' || err.code === 'ERR_LINK_PROJECT') {
|
||||
// do not show the message
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
client.output.prettyError(err);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
58
packages/cli/src/commands/promote/request-promote.ts
Normal file
58
packages/cli/src/commands/promote/request-promote.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import chalk from 'chalk';
|
||||
import type Client from '../../util/client';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import getProjectByDeployment from '../../util/projects/get-project-by-deployment';
|
||||
import ms from 'ms';
|
||||
import promoteStatus from './status';
|
||||
|
||||
/**
|
||||
* Requests a promotion and waits for it complete.
|
||||
* @param {Client} client - The Vercel client instance
|
||||
* @param {string} deployId - The deployment name or id to promote
|
||||
* @param {Project} project - Project info instance
|
||||
* @param {string} [timeout] - Time to poll for succeeded/failed state
|
||||
* @returns {Promise<number>} Resolves an exit code; 0 on success
|
||||
*/
|
||||
export default async function requestPromote({
|
||||
client,
|
||||
deployId,
|
||||
timeout,
|
||||
}: {
|
||||
client: Client;
|
||||
deployId: string;
|
||||
timeout?: string;
|
||||
}): Promise<number> {
|
||||
const { output } = client;
|
||||
|
||||
const { contextName, deployment, project } = await getProjectByDeployment({
|
||||
client,
|
||||
deployId,
|
||||
output: client.output,
|
||||
});
|
||||
|
||||
// request the promotion
|
||||
await client.fetch(`/v9/projects/${project.id}/promote/${deployment.id}`, {
|
||||
body: {}, // required
|
||||
json: false,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (timeout !== undefined && ms(timeout) === 0) {
|
||||
output.log(
|
||||
`Successfully requested promote of ${chalk.bold(project.name)} to ${
|
||||
deployment.url
|
||||
} (${deployment.id})`
|
||||
);
|
||||
output.log(`To check promote status, run ${getCommandName('promote')}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// check the status
|
||||
return await promoteStatus({
|
||||
client,
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
265
packages/cli/src/commands/promote/status.ts
Normal file
265
packages/cli/src/commands/promote/status.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import chalk from 'chalk';
|
||||
import type {
|
||||
LastAliasRequest,
|
||||
Deployment,
|
||||
PaginationOptions,
|
||||
Project,
|
||||
} from '@vercel-internals/types';
|
||||
import type Client from '../../util/client';
|
||||
import elapsed from '../../util/output/elapsed';
|
||||
import formatDate from '../../util/format-date';
|
||||
import getDeployment from '../../util/get-deployment';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
import getScope from '../../util/get-scope';
|
||||
import ms from 'ms';
|
||||
import sleep from '../../util/sleep';
|
||||
import getProjectByNameOrId from '../../util/projects/get-project-by-id-or-name';
|
||||
import { ProjectNotFound } from '../../util/errors-ts';
|
||||
import renderAliasStatus from '../../util/alias/render-alias-status';
|
||||
|
||||
interface DeploymentAlias {
|
||||
alias: {
|
||||
alias: string;
|
||||
deploymentId: string;
|
||||
};
|
||||
id: string;
|
||||
status: 'completed' | 'in-progress' | 'pending' | 'failed';
|
||||
}
|
||||
|
||||
interface AliasesResponse {
|
||||
aliases: DeploymentAlias[];
|
||||
pagination: PaginationOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuously checks a deployment status until it has succeeded, failed, or
|
||||
* taken longer than the timeout (default 3 minutes).
|
||||
*
|
||||
* @param {Client} client - The Vercel client instance
|
||||
* @param {string} [contextName] - The scope name; if not specified, it will be
|
||||
* extracted from the `client`
|
||||
* @param {Deployment} [deployment] - Info about the deployment which is used
|
||||
* to display different output following a promotion request
|
||||
* @param {Project} project - Project info instance
|
||||
* @param {string} [timeout] - Milliseconds to poll for succeeded/failed state
|
||||
* @returns {Promise<number>} Resolves an exit code; 0 on success
|
||||
*/
|
||||
export default async function promoteStatus({
|
||||
client,
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
timeout = '3m',
|
||||
}: {
|
||||
client: Client;
|
||||
contextName?: string;
|
||||
deployment?: Deployment;
|
||||
project: Project;
|
||||
timeout?: string;
|
||||
}): Promise<number> {
|
||||
const { output } = client;
|
||||
const recentThreshold = Date.now() - ms('3m');
|
||||
const promoteTimeout = Date.now() + ms(timeout);
|
||||
let counter = 0;
|
||||
let spinnerMessage = deployment
|
||||
? 'Promote in progress'
|
||||
: `Checking promotion status of ${project.name}`;
|
||||
|
||||
if (!contextName) {
|
||||
({ contextName } = await getScope(client));
|
||||
}
|
||||
|
||||
try {
|
||||
output.spinner(`${spinnerMessage}…`);
|
||||
|
||||
// continuously loop until the promotion has explicitly succeeded, failed,
|
||||
// or timed out
|
||||
for (;;) {
|
||||
const projectCheck = await getProjectByNameOrId(
|
||||
client,
|
||||
project.id,
|
||||
project.accountId,
|
||||
true
|
||||
);
|
||||
if (projectCheck instanceof ProjectNotFound) {
|
||||
throw projectCheck;
|
||||
}
|
||||
|
||||
const {
|
||||
jobStatus,
|
||||
requestedAt,
|
||||
toDeploymentId,
|
||||
type,
|
||||
}: Partial<LastAliasRequest> = projectCheck.lastAliasRequest ?? {};
|
||||
|
||||
if (
|
||||
!jobStatus ||
|
||||
(jobStatus !== 'in-progress' && jobStatus !== 'pending')
|
||||
) {
|
||||
output.stopSpinner();
|
||||
output.log(`${spinnerMessage}…`);
|
||||
}
|
||||
|
||||
if (
|
||||
!jobStatus ||
|
||||
!requestedAt ||
|
||||
!toDeploymentId ||
|
||||
requestedAt < recentThreshold
|
||||
) {
|
||||
output.log('No deployment promotion in progress');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (jobStatus === 'skipped' && type === 'promote') {
|
||||
output.log('Promote deployment was skipped');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (jobStatus === 'succeeded') {
|
||||
return await renderJobSucceeded({
|
||||
client,
|
||||
contextName,
|
||||
performingPromote: !!deployment,
|
||||
requestedAt,
|
||||
project,
|
||||
toDeploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (jobStatus === 'failed') {
|
||||
return await renderJobFailed({
|
||||
client,
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
toDeploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
// lastly, if we're not pending/in-progress, then we don't know what
|
||||
// the status is, so bail
|
||||
if (jobStatus !== 'pending' && jobStatus !== 'in-progress') {
|
||||
output.log(`Unknown promote deployment status "${jobStatus}"`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// check if we have been running for too long
|
||||
if (requestedAt < recentThreshold || Date.now() >= promoteTimeout) {
|
||||
output.log(
|
||||
`The promotion exceeded its deadline - rerun ${chalk.bold(
|
||||
`${getPkgName()} promote ${toDeploymentId}`
|
||||
)} to try again`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// if we've done our first poll and not rolling back, then print the
|
||||
// requested at date/time
|
||||
if (counter++ === 0 && !deployment) {
|
||||
spinnerMessage += ` requested at ${formatDate(requestedAt)}`;
|
||||
}
|
||||
output.spinner(`${spinnerMessage}…`);
|
||||
|
||||
await sleep(250);
|
||||
}
|
||||
} finally {
|
||||
output.stopSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
async function renderJobFailed({
|
||||
client,
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
toDeploymentId,
|
||||
}: {
|
||||
client: Client;
|
||||
contextName: string;
|
||||
deployment?: Deployment;
|
||||
project: Project;
|
||||
toDeploymentId: string;
|
||||
}) {
|
||||
const { output } = client;
|
||||
|
||||
try {
|
||||
const name = (
|
||||
deployment || (await getDeployment(client, contextName, toDeploymentId))
|
||||
)?.url;
|
||||
output.error(
|
||||
`Failed to remap all aliases to the requested deployment ${name} (${toDeploymentId})`
|
||||
);
|
||||
} catch (e) {
|
||||
output.error(
|
||||
`Failed to remap all aliases to the requested deployment ${toDeploymentId}`
|
||||
);
|
||||
}
|
||||
|
||||
// aliases are paginated, so continuously loop until all of them have been
|
||||
// fetched
|
||||
let nextTimestamp;
|
||||
for (;;) {
|
||||
let url = `/v9/projects/${project.id}/promote/aliases?failedOnly=true&limit=20`;
|
||||
if (nextTimestamp) {
|
||||
url += `&until=${nextTimestamp}`;
|
||||
}
|
||||
|
||||
const { aliases, pagination } = await client.fetch<AliasesResponse>(url);
|
||||
|
||||
for (const { alias, status } of aliases) {
|
||||
output.log(
|
||||
` ${renderAliasStatus(status).padEnd(11)} ${alias.alias} (${
|
||||
alias.deploymentId
|
||||
})`
|
||||
);
|
||||
}
|
||||
|
||||
if (pagination?.next) {
|
||||
nextTimestamp = pagination.next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function renderJobSucceeded({
|
||||
client,
|
||||
contextName,
|
||||
performingPromote,
|
||||
project,
|
||||
requestedAt,
|
||||
toDeploymentId,
|
||||
}: {
|
||||
client: Client;
|
||||
contextName: string;
|
||||
performingPromote: boolean;
|
||||
project: Project;
|
||||
requestedAt: number;
|
||||
toDeploymentId: string;
|
||||
}) {
|
||||
const { output } = client;
|
||||
|
||||
// attempt to get the new deployment url
|
||||
let deploymentInfo = '';
|
||||
try {
|
||||
const deployment = await getDeployment(client, contextName, toDeploymentId);
|
||||
deploymentInfo = `${chalk.bold(deployment.url)} (${toDeploymentId})`;
|
||||
} catch (err: any) {
|
||||
output.debug(
|
||||
`Failed to get deployment url for ${toDeploymentId}: ${
|
||||
err?.toString() || err
|
||||
}`
|
||||
);
|
||||
deploymentInfo = chalk.bold(toDeploymentId);
|
||||
}
|
||||
|
||||
const duration = performingPromote ? elapsed(Date.now() - requestedAt) : '';
|
||||
output.log(
|
||||
`Success! ${chalk.bold(
|
||||
project.name
|
||||
)} was promoted to ${deploymentInfo} ${duration}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -12,9 +12,7 @@ import validatePaths from '../util/validate-paths';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(
|
||||
`${logo} ${getPkgName()} rollback`
|
||||
)} [deploymentId|deploymentName]
|
||||
${chalk.bold(`${logo} ${getPkgName()} rollback`)} [deployment id/url]
|
||||
|
||||
Quickly revert back to a previous deployment.
|
||||
|
||||
|
||||
@@ -571,6 +571,9 @@ const main = async () => {
|
||||
case 'project':
|
||||
func = require('./commands/project').default;
|
||||
break;
|
||||
case 'promote':
|
||||
func = require('./commands/promote').default;
|
||||
break;
|
||||
case 'pull':
|
||||
func = require('./commands/pull').default;
|
||||
break;
|
||||
|
||||
58
packages/cli/src/util/projects/get-project-by-cwd-or-link.ts
Normal file
58
packages/cli/src/util/projects/get-project-by-cwd-or-link.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type Client from '../client';
|
||||
import { ProjectNotFound } from '../errors-ts';
|
||||
import { ensureLink } from '../link/ensure-link';
|
||||
import validatePaths from '../validate-paths';
|
||||
import getProjectByNameOrId from './get-project-by-id-or-name';
|
||||
import type { Project } from '@vercel-internals/types';
|
||||
|
||||
export default async function getProjectByCwdOrLink({
|
||||
autoConfirm,
|
||||
client,
|
||||
commandName,
|
||||
cwd,
|
||||
projectNameOrId,
|
||||
}: {
|
||||
autoConfirm?: boolean;
|
||||
client: Client;
|
||||
commandName: string;
|
||||
cwd: string;
|
||||
projectNameOrId?: string;
|
||||
}): Promise<Project> {
|
||||
if (projectNameOrId) {
|
||||
const project = await getProjectByNameOrId(client, projectNameOrId);
|
||||
if (project instanceof ProjectNotFound) {
|
||||
throw project;
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
const pathValidation = await validatePaths(client, [cwd]);
|
||||
if (!pathValidation.valid) {
|
||||
if (pathValidation.exitCode) {
|
||||
const err: NodeJS.ErrnoException = new Error(
|
||||
'Invalid current working directory'
|
||||
);
|
||||
err.code = 'ERR_INVALID_CWD';
|
||||
throw err;
|
||||
}
|
||||
const err: NodeJS.ErrnoException = new Error('Canceled');
|
||||
err.code = 'ERR_CANCELED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ensure the current directory is a linked project
|
||||
const linkedProject = await ensureLink(
|
||||
commandName,
|
||||
client,
|
||||
pathValidation.path,
|
||||
{ autoConfirm }
|
||||
);
|
||||
|
||||
if (typeof linkedProject === 'number') {
|
||||
const err: NodeJS.ErrnoException = new Error('Link project error');
|
||||
err.code = 'ERR_LINK_PROJECT';
|
||||
throw err;
|
||||
}
|
||||
|
||||
return linkedProject.project;
|
||||
}
|
||||
104
packages/cli/src/util/projects/get-project-by-deployment.ts
Normal file
104
packages/cli/src/util/projects/get-project-by-deployment.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import chalk from 'chalk';
|
||||
import type Client from '../client';
|
||||
import type { Deployment, Project, Team } from '@vercel-internals/types';
|
||||
import getDeployment from '../get-deployment';
|
||||
import getProjectByNameOrId from './get-project-by-id-or-name';
|
||||
import getScope from '../get-scope';
|
||||
import getTeamById from '../teams/get-team-by-id';
|
||||
import { isValidName } from '../is-valid-name';
|
||||
import { Output } from '../output';
|
||||
import { ProjectNotFound } from '../errors-ts';
|
||||
|
||||
export default async function getProjectByDeployment({
|
||||
client,
|
||||
deployId,
|
||||
output,
|
||||
}: {
|
||||
client: Client;
|
||||
deployId: string;
|
||||
output?: Output;
|
||||
}): Promise<{
|
||||
contextName: string;
|
||||
deployment: Deployment;
|
||||
project: Project;
|
||||
}> {
|
||||
const { config } = client;
|
||||
const { contextName } = await getScope(client);
|
||||
|
||||
if (!isValidName(deployId)) {
|
||||
throw new Error(
|
||||
`The provided argument "${deployId}" is not a valid deployment`
|
||||
);
|
||||
}
|
||||
|
||||
let deployment: Deployment;
|
||||
let team: Team | undefined;
|
||||
|
||||
try {
|
||||
output?.spinner(
|
||||
`Fetching deployment "${deployId}" in ${chalk.bold(contextName)}…`
|
||||
);
|
||||
|
||||
const [teamResult, deploymentResult] = await Promise.allSettled([
|
||||
config.currentTeam ? getTeamById(client, config.currentTeam) : undefined,
|
||||
getDeployment(client, contextName, deployId),
|
||||
]);
|
||||
|
||||
if (teamResult.status === 'rejected') {
|
||||
throw new Error(
|
||||
`Failed to retrieve team information: ${teamResult.reason}`
|
||||
);
|
||||
}
|
||||
|
||||
if (deploymentResult.status === 'rejected') {
|
||||
throw new Error(deploymentResult.reason);
|
||||
}
|
||||
|
||||
team = teamResult.value;
|
||||
deployment = deploymentResult.value;
|
||||
|
||||
// re-render the spinner text
|
||||
output?.log(
|
||||
`Fetching deployment "${deployId}" in ${chalk.bold(contextName)}…`
|
||||
);
|
||||
|
||||
if (deployment.team?.id) {
|
||||
if (!team || deployment.team.id !== team.id) {
|
||||
const err: NodeJS.ErrnoException = new Error(
|
||||
team
|
||||
? `Deployment doesn't belong to current team ${chalk.bold(
|
||||
contextName
|
||||
)}`
|
||||
: `Deployment belongs to a different team`
|
||||
);
|
||||
err.code = 'ERR_INVALID_TEAM';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (team) {
|
||||
const err: NodeJS.ErrnoException = new Error(
|
||||
`Deployment doesn't belong to current team ${chalk.bold(contextName)}`
|
||||
);
|
||||
err.code = 'ERR_INVALID_TEAM';
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!deployment.projectId) {
|
||||
throw new Error('Deployment is not associated to a project');
|
||||
}
|
||||
|
||||
const project = await getProjectByNameOrId(client, deployment.projectId);
|
||||
if (project instanceof ProjectNotFound) {
|
||||
throw project;
|
||||
}
|
||||
|
||||
return {
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
};
|
||||
} finally {
|
||||
output?.stopSpinner();
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { isAPIError, ProjectNotFound } from '../errors-ts';
|
||||
export default async function getProjectByNameOrId(
|
||||
client: Client,
|
||||
projectNameOrId: string,
|
||||
accountId?: string
|
||||
accountId?: string,
|
||||
includeRollbackInfo?: boolean
|
||||
) {
|
||||
try {
|
||||
const qs = includeRollbackInfo ? '?rollbackInfo=true' : '';
|
||||
const project = await client.fetch<Project>(
|
||||
`/v8/projects/${encodeURIComponent(projectNameOrId)}`,
|
||||
`/v9/projects/${encodeURIComponent(projectNameOrId)}${qs}`,
|
||||
{ accountId }
|
||||
);
|
||||
return project;
|
||||
|
||||
@@ -11,7 +11,7 @@ import formatDate from '../format-date';
|
||||
import getDeployment from '../get-deployment';
|
||||
import getScope from '../get-scope';
|
||||
import ms from 'ms';
|
||||
import renderAliasStatus from './render-alias-status';
|
||||
import renderAliasStatus from '../alias/render-alias-status';
|
||||
import sleep from '../sleep';
|
||||
|
||||
interface RollbackAlias {
|
||||
|
||||
3
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/.gitignore
vendored
Normal file
3
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.next
|
||||
yarn.lock
|
||||
!.vercel
|
||||
4
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/.vercel/project.json
vendored
Normal file
4
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/.vercel/project.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"orgId": "team_dummy",
|
||||
"projectId": "vercel-promote"
|
||||
}
|
||||
12
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/package.json
vendored
Normal file
12
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/package.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next",
|
||||
"now-build": "next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "13.4.3",
|
||||
"react": "latest",
|
||||
"react-dom": "latest"
|
||||
}
|
||||
}
|
||||
11
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/pages/index.js
vendored
Normal file
11
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/pages/index.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { withRouter } from 'next/router';
|
||||
|
||||
function Index({ router }) {
|
||||
const data = {
|
||||
pathname: router.pathname,
|
||||
query: router.query,
|
||||
};
|
||||
return <div>{JSON.stringify(data)}</div>;
|
||||
}
|
||||
|
||||
export default withRouter(Index);
|
||||
10
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/vercel.json
vendored
Normal file
10
packages/cli/test/fixtures/unit/commands/promote/simple-next-site/vercel.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 2,
|
||||
"name": "vercel-promote",
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/index?route-param=b"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import chance from 'chance';
|
||||
import { client } from './client';
|
||||
import { Build, Deployment, User } from '@vercel-internals/types';
|
||||
import type { Request, Response } from 'express';
|
||||
import { defaultProject } from './project';
|
||||
|
||||
let deployments = new Map<string, Deployment>();
|
||||
let deploymentBuilds = new Map<Deployment, Build[]>();
|
||||
@@ -16,6 +17,7 @@ export function useDeployment({
|
||||
creator,
|
||||
state = 'READY',
|
||||
createdAt,
|
||||
project = defaultProject,
|
||||
}: {
|
||||
creator: Pick<User, 'id' | 'email' | 'name' | 'username'>;
|
||||
state?:
|
||||
@@ -26,6 +28,7 @@ export function useDeployment({
|
||||
| 'READY'
|
||||
| 'CANCELED';
|
||||
createdAt?: number;
|
||||
project: any; // FIX ME: Use `Project` once PR #9956 is merged
|
||||
}) {
|
||||
setupDeploymentEndpoints();
|
||||
|
||||
@@ -53,6 +56,7 @@ export function useDeployment({
|
||||
name,
|
||||
ownerId: creator.id,
|
||||
plan: 'hobby',
|
||||
projectId: project.id,
|
||||
public: false,
|
||||
ready: createdAt + 30000,
|
||||
readyState: state,
|
||||
|
||||
@@ -128,6 +128,7 @@ export const defaultProject: Project = {
|
||||
url: 'a-project-name-rjtr4pz3f.vercel.app',
|
||||
},
|
||||
],
|
||||
lastAliasRequest: null,
|
||||
lastRollbackTarget: null,
|
||||
alias: [
|
||||
{
|
||||
@@ -205,10 +206,10 @@ export function useProject(
|
||||
project: Partial<Project> = defaultProject,
|
||||
projectEnvs: ProjectEnvVariable[] = envs
|
||||
) {
|
||||
client.scenario.get(`/v8/projects/${project.name}`, (_req, res) => {
|
||||
client.scenario.get(`/:version/projects/${project.name}`, (_req, res) => {
|
||||
res.json(project);
|
||||
});
|
||||
client.scenario.get(`/v8/projects/${project.id}`, (_req, res) => {
|
||||
client.scenario.get(`/:version/projects/${project.id}`, (_req, res) => {
|
||||
res.json(project);
|
||||
});
|
||||
client.scenario.patch(`/:version/projects/${project.id}`, (req, res) => {
|
||||
|
||||
380
packages/cli/test/unit/commands/promote.test.ts
Normal file
380
packages/cli/test/unit/commands/promote.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import chalk from 'chalk';
|
||||
import { client } from '../../mocks/client';
|
||||
import { defaultProject, useProject } from '../../mocks/project';
|
||||
import { Request, Response } from 'express';
|
||||
import promote from '../../../src/commands/promote';
|
||||
import { LastAliasRequest } from '@vercel-internals/types';
|
||||
import { setupUnitFixture } from '../../helpers/setup-unit-fixture';
|
||||
import { useDeployment } from '../../mocks/deployment';
|
||||
import { useTeams } from '../../mocks/team';
|
||||
import { useUser } from '../../mocks/user';
|
||||
import sleep from '../../../src/util/sleep';
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe('promote', () => {
|
||||
it('should error if cwd is invalid', async () => {
|
||||
client.setArgv('promote', '--cwd', __filename);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: Support for single file deployments has been removed.'
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if timeout is invalid', async () => {
|
||||
const { cwd } = initPromoteTest();
|
||||
client.setArgv('promote', '--yes', '--cwd', cwd, '--timeout', 'foo');
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput('Error: Invalid timeout "foo"');
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if invalid deployment ID', async () => {
|
||||
const { cwd } = initPromoteTest();
|
||||
client.setArgv('promote', '????', '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: The provided argument "????" is not a valid deployment'
|
||||
);
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if deployment not found', async () => {
|
||||
const { cwd } = initPromoteTest();
|
||||
client.setArgv('promote', 'foo', '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput('Fetching deployment "foo" in ');
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: Error: Can\'t find the deployment "foo" under the context'
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should show status when not promoting', async () => {
|
||||
const { cwd } = initPromoteTest();
|
||||
client.setArgv('promote', '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
'Checking promotion status of vercel-promote'
|
||||
);
|
||||
await expect(client.stderr).toOutput('No deployment promotion in progress');
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
|
||||
it('should promote by deployment id', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest();
|
||||
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput('Promote in progress');
|
||||
await expect(client.stderr).toOutput(
|
||||
`Success! ${chalk.bold('vercel-promote')} was promoted to ${
|
||||
previousDeployment.url
|
||||
} (${previousDeployment.id})`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
|
||||
it('should promote by deployment url', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest();
|
||||
client.setArgv('promote', previousDeployment.url, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.url}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput('Promote in progress');
|
||||
await expect(client.stderr).toOutput(
|
||||
`Success! ${chalk.bold('vercel-promote')} was promoted to ${
|
||||
previousDeployment.url
|
||||
} (${previousDeployment.id})`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
|
||||
it('should get status while promoting', async () => {
|
||||
const { cwd, previousDeployment, project } = initPromoteTest({
|
||||
promotePollCount: 10,
|
||||
});
|
||||
|
||||
// start the promote
|
||||
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
promote(client);
|
||||
|
||||
// need to wait for the promote request to be accepted
|
||||
await sleep(300);
|
||||
|
||||
// get the status
|
||||
client.setArgv('promote', '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Checking promotion status of ${project.name}`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Success! ${chalk.bold('vercel-promote')} was promoted to ${
|
||||
previousDeployment.url
|
||||
} (${previousDeployment.id})`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
|
||||
it('should error if promote request fails', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest({
|
||||
promotePollCount: 10,
|
||||
promoteStatusCode: 500,
|
||||
});
|
||||
|
||||
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
|
||||
// we need to wait a super long time because fetch will return on 500
|
||||
await expect(client.stderr).toOutput('Response Error (500)', 20000);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if promote fails (no aliases)', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest({
|
||||
promoteJobStatus: 'failed',
|
||||
});
|
||||
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput('Promote in progress');
|
||||
await expect(client.stderr).toOutput(
|
||||
`Error: Failed to remap all aliases to the requested deployment ${previousDeployment.url} (${previousDeployment.id})`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if promote fails (with aliases)', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest({
|
||||
promoteAliases: [
|
||||
{
|
||||
alias: { alias: 'foo', deploymentId: 'foo_123' },
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
alias: { alias: 'bar', deploymentId: 'bar_123' },
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
promoteJobStatus: 'failed',
|
||||
});
|
||||
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput('Promote in progress');
|
||||
await expect(client.stderr).toOutput(
|
||||
`Error: Failed to remap all aliases to the requested deployment ${previousDeployment.url} (${previousDeployment.id})`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
` ${chalk.green('completed')} foo (foo_123)`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
` ${chalk.red('failed')} bar (bar_123)`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if deployment times out', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest({
|
||||
promotePollCount: 10,
|
||||
});
|
||||
client.setArgv(
|
||||
'promote',
|
||||
previousDeployment.id,
|
||||
'--yes',
|
||||
'--cwd',
|
||||
cwd,
|
||||
'--timeout',
|
||||
'1'
|
||||
);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput('Promote in progress');
|
||||
await expect(client.stderr).toOutput(
|
||||
`The promotion exceeded its deadline - rerun ${chalk.bold(
|
||||
`vercel promote ${previousDeployment.id}`
|
||||
)} to try again`,
|
||||
10000
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should immediately exit after requesting promote', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest();
|
||||
client.setArgv(
|
||||
'promote',
|
||||
previousDeployment.id,
|
||||
'--yes',
|
||||
'--cwd',
|
||||
cwd,
|
||||
'--timeout',
|
||||
'0'
|
||||
);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Successfully requested promote of ${chalk.bold('vercel-promote')} to ${
|
||||
previousDeployment.url
|
||||
} (${previousDeployment.id})`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
|
||||
it('should error if deployment belongs to different team', async () => {
|
||||
const { cwd, previousDeployment } = initPromoteTest();
|
||||
previousDeployment.team = {
|
||||
id: 'abc',
|
||||
name: 'abc',
|
||||
slug: 'abc',
|
||||
};
|
||||
client.setArgv('promote', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = promote(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: Deployment belongs to a different team'
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
type DeploymentAlias = {
|
||||
alias: {
|
||||
alias: string;
|
||||
deploymentId: string;
|
||||
};
|
||||
status: string;
|
||||
};
|
||||
|
||||
function initPromoteTest({
|
||||
promoteAliases = [],
|
||||
promoteJobStatus = 'succeeded',
|
||||
promotePollCount = 2,
|
||||
promoteStatusCode,
|
||||
}: {
|
||||
promoteAliases?: DeploymentAlias[];
|
||||
promoteJobStatus?: LastAliasRequest['jobStatus'];
|
||||
promotePollCount?: number;
|
||||
promoteStatusCode?: number;
|
||||
} = {}) {
|
||||
const cwd = setupUnitFixture('commands/promote/simple-next-site');
|
||||
const user = useUser();
|
||||
useTeams('team_dummy');
|
||||
const { project } = useProject({
|
||||
...defaultProject,
|
||||
id: 'vercel-promote',
|
||||
name: 'vercel-promote',
|
||||
});
|
||||
|
||||
const currentDeployment = useDeployment({ creator: user, project });
|
||||
const previousDeployment = useDeployment({ creator: user, project });
|
||||
|
||||
let pollCounter = 0;
|
||||
let lastAliasRequest: LastAliasRequest | null = null;
|
||||
|
||||
client.scenario.post(
|
||||
'/:version/projects/:project/promote/:id',
|
||||
(req: Request, res: Response) => {
|
||||
if (promoteStatusCode === 500) {
|
||||
res.statusCode = 500;
|
||||
res.end('Server error');
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
if (previousDeployment.id !== id) {
|
||||
res.statusCode = 404;
|
||||
res.json({
|
||||
error: { code: 'not_found', message: 'Deployment not found', id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lastAliasRequest = {
|
||||
fromDeploymentId: currentDeployment.id,
|
||||
jobStatus: 'in-progress',
|
||||
requestedAt: Date.now(),
|
||||
toDeploymentId: id,
|
||||
type: 'promote',
|
||||
};
|
||||
|
||||
Object.defineProperty(project, 'lastAliasRequest', {
|
||||
get(): LastAliasRequest | null {
|
||||
if (
|
||||
lastAliasRequest &&
|
||||
promotePollCount !== undefined &&
|
||||
pollCounter++ > promotePollCount
|
||||
) {
|
||||
lastAliasRequest.jobStatus = promoteJobStatus;
|
||||
}
|
||||
return lastAliasRequest;
|
||||
},
|
||||
set(value: LastAliasRequest | null) {
|
||||
lastAliasRequest = value;
|
||||
},
|
||||
});
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end();
|
||||
}
|
||||
);
|
||||
|
||||
client.scenario.get(
|
||||
'/:version/projects/:project/promote/aliases',
|
||||
(req, res) => {
|
||||
res.json({
|
||||
aliases: promoteAliases,
|
||||
pagination: null,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
cwd,
|
||||
project,
|
||||
currentDeployment,
|
||||
previousDeployment,
|
||||
};
|
||||
}
|
||||
@@ -317,6 +317,8 @@ function initRollbackTest({
|
||||
|
||||
const currentDeployment = useDeployment({ creator: user });
|
||||
const previousDeployment = useDeployment({ creator: user });
|
||||
|
||||
let pollCounter = 0;
|
||||
let lastRollbackTarget: RollbackTarget | null = null;
|
||||
|
||||
client.scenario.post(
|
||||
@@ -343,6 +345,23 @@ function initRollbackTest({
|
||||
requestedAt: Date.now(),
|
||||
toDeploymentId: id,
|
||||
};
|
||||
|
||||
Object.defineProperty(project, 'lastRollbackTarget', {
|
||||
get(): RollbackTarget | null {
|
||||
if (
|
||||
lastRollbackTarget &&
|
||||
rollbackPollCount !== undefined &&
|
||||
pollCounter++ > rollbackPollCount
|
||||
) {
|
||||
lastRollbackTarget.jobStatus = rollbackJobStatus;
|
||||
}
|
||||
return lastRollbackTarget;
|
||||
},
|
||||
set(value: RollbackTarget | null) {
|
||||
lastRollbackTarget = value;
|
||||
},
|
||||
});
|
||||
|
||||
res.statusCode = 201;
|
||||
res.end();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user