mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-10 04:22:12 +00:00
[cli] New vc redeploy command (#9956)
This adds a new `vc redeploy <url-or-id>` command. It fetches the requested deployment, then performs a redeploy with similar output to `vc deploy` including the ability to pipe the deployment URL into a file or program. ### Redeploy Example: <img width="650" alt="image" src="https://github.com/vercel/vercel/assets/97262/b17fc424-558b-415c-8b74-63e450f4b753"> ### Bad deployment URL: <img width="579" alt="image" src="https://github.com/vercel/vercel/assets/97262/0cb53209-396e-4490-b5d0-744d5d870aaf"> ### No args: <img width="622" alt="image" src="https://github.com/vercel/vercel/assets/97262/cb36d625-991b-41fa-bb49-d7d36c1a201b"> Linear: https://linear.app/vercel/issue/VCCLI-558/cli-new-command-to-redeploy
This commit is contained in:
6
.changeset/orange-zebras-return.md
Normal file
6
.changeset/orange-zebras-return.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@vercel/client': minor
|
||||
'vercel': minor
|
||||
---
|
||||
|
||||
New `vc redeploy` command
|
||||
2
internals/types/index.d.ts
vendored
2
internals/types/index.d.ts
vendored
@@ -157,6 +157,7 @@ export type Deployment = {
|
||||
errorLink?: string;
|
||||
errorMessage?: string | null;
|
||||
errorStep?: string;
|
||||
forced?: boolean;
|
||||
functions?: BuilderFunctions | null;
|
||||
gitSource?: {
|
||||
org?: string;
|
||||
@@ -183,6 +184,7 @@ export type Deployment = {
|
||||
ownerId?: string;
|
||||
plan?: 'enterprise' | 'hobby' | 'oss' | 'pro';
|
||||
previewCommentsEnabled?: boolean;
|
||||
private?: boolean;
|
||||
projectId?: string;
|
||||
projectSettings?: {
|
||||
buildCommand?: string | null;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const help = () => `
|
||||
login [email] Logs into your account or creates a new one
|
||||
logout Logs out of your account
|
||||
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
|
||||
switch [scope] Switches between teams and your personal account
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import stamp from '../../util/output/stamp';
|
||||
import createDeploy from '../../util/deploy/create-deploy';
|
||||
import getDeployment from '../../util/get-deployment';
|
||||
import parseMeta from '../../util/parse-meta';
|
||||
import linkStyle from '../../util/output/link';
|
||||
import param from '../../util/output/param';
|
||||
import {
|
||||
BuildsRateLimited,
|
||||
@@ -59,7 +58,6 @@ import validatePaths, {
|
||||
validateRootDirectory,
|
||||
} from '../../util/validate-paths';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import { getPreferredPreviewURL } from '../../util/deploy/get-preferred-preview-url';
|
||||
import { Output } from '../../util/output';
|
||||
import { help } from './args';
|
||||
import { getDeploymentChecks } from '../../util/deploy/get-deployment-checks';
|
||||
@@ -70,8 +68,7 @@ import { isValidArchive } from '../../util/deploy/validate-archive-format';
|
||||
import { parseEnv } from '../../util/parse-env';
|
||||
import { errorToString, isErrnoException, isError } from '@vercel/error-utils';
|
||||
import { pickOverrides } from '../../util/projects/project-settings';
|
||||
import { isDeploying } from '../../util/deploy/is-deploying';
|
||||
import type { Deployment } from '@vercel-internals/types';
|
||||
import { printDeploymentStatus } from '../../util/deploy/print-deployment-status';
|
||||
|
||||
export default async (client: Client): Promise<number> => {
|
||||
const { output } = client;
|
||||
@@ -728,7 +725,7 @@ export default async (client: Client): Promise<number> => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return printDeploymentStatus(output, client, deployment, deployStamp, noWait);
|
||||
return printDeploymentStatus(client, deployment, deployStamp, noWait);
|
||||
};
|
||||
|
||||
function handleCreateDeployError(
|
||||
@@ -835,112 +832,3 @@ const addProcessEnv = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const printDeploymentStatus = async (
|
||||
output: Output,
|
||||
client: Client,
|
||||
{
|
||||
readyState,
|
||||
alias: aliasList,
|
||||
aliasError,
|
||||
target,
|
||||
indications,
|
||||
url: deploymentUrl,
|
||||
aliasWarning,
|
||||
}: {
|
||||
readyState: Deployment['readyState'];
|
||||
alias: string[];
|
||||
aliasError: Error;
|
||||
target: string;
|
||||
indications: any;
|
||||
url: string;
|
||||
aliasWarning?: {
|
||||
code: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
action?: string;
|
||||
};
|
||||
},
|
||||
deployStamp: () => string,
|
||||
noWait: boolean
|
||||
) => {
|
||||
indications = indications || [];
|
||||
const isProdDeployment = target === 'production';
|
||||
|
||||
let isStillBuilding = false;
|
||||
if (noWait) {
|
||||
if (isDeploying(readyState)) {
|
||||
isStillBuilding = true;
|
||||
output.print(
|
||||
prependEmoji(
|
||||
'Note: Deployment is still processing...',
|
||||
emoji('notice')
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStillBuilding && readyState !== 'READY') {
|
||||
output.error(
|
||||
`Your deployment failed. Please retry later. More: https://err.sh/vercel/deployment-error`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (aliasError) {
|
||||
output.warn(
|
||||
`Failed to assign aliases${
|
||||
aliasError.message ? `: ${aliasError.message}` : ''
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
// print preview/production url
|
||||
let previewUrl: string;
|
||||
// if `noWait` is true, then use the deployment url, not an alias
|
||||
if (!noWait && Array.isArray(aliasList) && aliasList.length > 0) {
|
||||
const previewUrlInfo = await getPreferredPreviewURL(client, aliasList);
|
||||
if (previewUrlInfo) {
|
||||
previewUrl = previewUrlInfo.previewUrl;
|
||||
} else {
|
||||
previewUrl = `https://${deploymentUrl}`;
|
||||
}
|
||||
} else {
|
||||
// fallback to deployment url
|
||||
previewUrl = `https://${deploymentUrl}`;
|
||||
}
|
||||
|
||||
output.print(
|
||||
prependEmoji(
|
||||
`${isProdDeployment ? 'Production' : 'Preview'}: ${chalk.bold(
|
||||
previewUrl
|
||||
)} ${deployStamp()}`,
|
||||
emoji('success')
|
||||
) + `\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (aliasWarning?.message) {
|
||||
indications.push({
|
||||
type: 'warning',
|
||||
payload: aliasWarning.message,
|
||||
link: aliasWarning.link,
|
||||
action: aliasWarning.action,
|
||||
});
|
||||
}
|
||||
|
||||
const newline = '\n';
|
||||
for (let indication of indications) {
|
||||
const message =
|
||||
prependEmoji(chalk.dim(indication.payload), emoji(indication.type)) +
|
||||
newline;
|
||||
let link = '';
|
||||
if (indication.link)
|
||||
link =
|
||||
chalk.dim(
|
||||
`${indication.action || 'Learn More'}: ${linkStyle(indication.link)}`
|
||||
) + newline;
|
||||
output.print(message + link);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ export default new Map([
|
||||
['project', 'project'],
|
||||
['projects', 'project'],
|
||||
['pull', 'pull'],
|
||||
['redeploy', 'redeploy'],
|
||||
['remove', 'remove'],
|
||||
['rm', 'remove'],
|
||||
['rollback', 'rollback'],
|
||||
|
||||
204
packages/cli/src/commands/redeploy.ts
Normal file
204
packages/cli/src/commands/redeploy.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import chalk from 'chalk';
|
||||
import { checkDeploymentStatus } from '@vercel/client';
|
||||
import type Client from '../util/client';
|
||||
import { emoji, prependEmoji } from '../util/emoji';
|
||||
import getArgs from '../util/get-args';
|
||||
import { getCommandName, getPkgName } from '../util/pkg-name';
|
||||
import { getDeploymentByIdOrURL } from '../util/deploy/get-deployment-by-id-or-url';
|
||||
import getScope from '../util/get-scope';
|
||||
import handleError from '../util/handle-error';
|
||||
import { isErrnoException } from '@vercel/error-utils';
|
||||
import logo from '../util/output/logo';
|
||||
import Now from '../util';
|
||||
import { printDeploymentStatus } from '../util/deploy/print-deployment-status';
|
||||
import stamp from '../util/output/stamp';
|
||||
import ua from '../util/ua';
|
||||
import type { VercelClientOptions } from '@vercel/client';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(
|
||||
`${logo} ${getPkgName()} redeploy`
|
||||
)} [deploymentId|deploymentName]
|
||||
|
||||
Rebuild and deploy a previous deployment.
|
||||
|
||||
${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]
|
||||
--no-wait Don't wait for the redeploy to finish
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
-y, --yes Skip questions when setting up new project using default scope and settings
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Rebuild and deploy an existing deployment using id or url
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} redeploy my-deployment.vercel.app`)}
|
||||
|
||||
${chalk.gray('–')} Write Deployment URL to a file
|
||||
|
||||
${chalk.cyan(
|
||||
`$ ${getPkgName()} redeploy my-deployment.vercel.app > deployment-url.txt`
|
||||
)}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* `vc redeploy` 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), {
|
||||
'--no-wait': Boolean,
|
||||
'--yes': Boolean,
|
||||
'-y': '--yes',
|
||||
});
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (argv['--help'] || argv._[0] === 'help') {
|
||||
help();
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { output } = client;
|
||||
const deployIdOrUrl = argv._[1];
|
||||
if (!deployIdOrUrl) {
|
||||
output.error(
|
||||
`Missing required deployment id or url: ${getCommandName(
|
||||
`redeploy <deployment-id-or-url>`
|
||||
)}`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { contextName } = await getScope(client);
|
||||
const noWait = !!argv['--no-wait'];
|
||||
|
||||
try {
|
||||
const fromDeployment = await getDeploymentByIdOrURL({
|
||||
client,
|
||||
contextName,
|
||||
deployIdOrUrl,
|
||||
});
|
||||
|
||||
const deployStamp = stamp();
|
||||
output.spinner(`Redeploying project ${fromDeployment.id}`, 0);
|
||||
|
||||
let deployment = await client.fetch<any>(`/v13/deployments?forceNew=1`, {
|
||||
body: {
|
||||
deploymentId: fromDeployment.id,
|
||||
meta: {
|
||||
action: 'redeploy',
|
||||
},
|
||||
name: fromDeployment.name,
|
||||
target: fromDeployment.target || 'production',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
output.stopSpinner();
|
||||
output.print(
|
||||
`${prependEmoji(
|
||||
`Inspect: ${chalk.bold(deployment.inspectorUrl)} ${deployStamp()}`,
|
||||
emoji('inspect')
|
||||
)}\n`
|
||||
);
|
||||
|
||||
if (!client.stdout.isTTY) {
|
||||
client.stdout.write(`https://${deployment.url}`);
|
||||
}
|
||||
|
||||
if (!noWait) {
|
||||
output.spinner(
|
||||
deployment.readyState === 'QUEUED' ? 'Queued' : 'Building',
|
||||
0
|
||||
);
|
||||
|
||||
if (deployment.readyState === 'READY' && deployment.aliasAssigned) {
|
||||
output.spinner('Completing', 0);
|
||||
} else {
|
||||
try {
|
||||
const clientOptions: VercelClientOptions = {
|
||||
agent: client.agent,
|
||||
apiUrl: client.apiUrl,
|
||||
debug: client.output.debugEnabled,
|
||||
path: '', // unused by checkDeploymentStatus()
|
||||
teamId: fromDeployment.team?.id,
|
||||
token: client.authConfig.token!,
|
||||
userAgent: ua,
|
||||
};
|
||||
|
||||
for await (const event of checkDeploymentStatus(
|
||||
deployment,
|
||||
clientOptions
|
||||
)) {
|
||||
if (event.type === 'building') {
|
||||
output.spinner('Building', 0);
|
||||
} else if (
|
||||
event.type === 'ready' &&
|
||||
((event.payload as any).checksState
|
||||
? (event.payload as any).checksState === 'completed'
|
||||
: true)
|
||||
) {
|
||||
output.spinner('Completing', 0);
|
||||
} else if (event.type === 'checks-running') {
|
||||
output.spinner('Running Checks', 0);
|
||||
} else if (
|
||||
event.type === 'alias-assigned' ||
|
||||
event.type === 'checks-conclusion-failed'
|
||||
) {
|
||||
output.stopSpinner();
|
||||
deployment = event.payload;
|
||||
break;
|
||||
} else if (event.type === 'canceled') {
|
||||
output.stopSpinner();
|
||||
output.print('The deployment has been canceled.\n');
|
||||
return 1;
|
||||
} else if (event.type === 'error') {
|
||||
output.stopSpinner();
|
||||
|
||||
const now = new Now({
|
||||
client,
|
||||
currentTeam: fromDeployment.team?.id,
|
||||
});
|
||||
const error = await now.handleDeploymentError(event.payload, {
|
||||
env: {},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
output.prettyError(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return printDeploymentStatus(client, deployment, deployStamp, noWait);
|
||||
} catch (err: unknown) {
|
||||
output.prettyError(err);
|
||||
if (isErrnoException(err) && err.code === 'ERR_INVALID_TEAM') {
|
||||
output.error(
|
||||
`Use ${chalk.bold('vc switch')} to change your current team`
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
@@ -116,7 +116,7 @@ export default async (client: Client): Promise<number> => {
|
||||
|
||||
return await requestRollback({
|
||||
client,
|
||||
deployId: actionOrDeployId,
|
||||
deployIdOrUrl: actionOrDeployId,
|
||||
project,
|
||||
timeout,
|
||||
});
|
||||
|
||||
@@ -574,6 +574,9 @@ const main = async () => {
|
||||
case 'pull':
|
||||
func = require('./commands/pull').default;
|
||||
break;
|
||||
case 'redeploy':
|
||||
func = require('./commands/redeploy').default;
|
||||
break;
|
||||
case 'remove':
|
||||
func = require('./commands/remove').default;
|
||||
break;
|
||||
|
||||
89
packages/cli/src/util/deploy/get-deployment-by-id-or-url.ts
Normal file
89
packages/cli/src/util/deploy/get-deployment-by-id-or-url.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import chalk from 'chalk';
|
||||
import getDeployment from '../get-deployment';
|
||||
import getTeamById from '../teams/get-team-by-id';
|
||||
import { isValidName } from '../is-valid-name';
|
||||
import type Client from '../client';
|
||||
import type { Deployment, Team } from '@vercel-internals/types';
|
||||
|
||||
/**
|
||||
* Renders feedback while retrieving a deployment, then validates the
|
||||
* deployment belongs to the current team.
|
||||
*
|
||||
* @param client - The CLI client instance.
|
||||
* @param contextName - The context/team name.
|
||||
* @param deployIdOrUrl - The deployment id or URL.
|
||||
* @returns The deployment info.
|
||||
*/
|
||||
export async function getDeploymentByIdOrURL({
|
||||
client,
|
||||
contextName,
|
||||
deployIdOrUrl,
|
||||
}: {
|
||||
client: Client;
|
||||
contextName: string;
|
||||
deployIdOrUrl: string;
|
||||
}): Promise<Deployment> {
|
||||
const { config, output } = client;
|
||||
|
||||
if (!isValidName(deployIdOrUrl)) {
|
||||
throw new Error(
|
||||
`The provided argument "${deployIdOrUrl}" is not a valid deployment ID or URL`
|
||||
);
|
||||
}
|
||||
|
||||
let deployment: Deployment;
|
||||
let team: Team | undefined;
|
||||
|
||||
try {
|
||||
output.spinner(
|
||||
`Fetching deployment "${deployIdOrUrl}" in ${chalk.bold(contextName)}…`
|
||||
);
|
||||
|
||||
const [teamResult, deploymentResult] = await Promise.allSettled([
|
||||
config.currentTeam ? getTeamById(client, config.currentTeam) : undefined,
|
||||
getDeployment(client, contextName, deployIdOrUrl),
|
||||
]);
|
||||
|
||||
if (teamResult.status === 'rejected') {
|
||||
throw new Error(
|
||||
`Failed to retrieve team information: ${teamResult.reason}`
|
||||
);
|
||||
}
|
||||
|
||||
if (deploymentResult.status === 'rejected') {
|
||||
throw new Error(deploymentResult.reason.message);
|
||||
}
|
||||
|
||||
team = teamResult.value;
|
||||
deployment = deploymentResult.value;
|
||||
|
||||
// re-render the spinner text because it goes so fast
|
||||
output.log(
|
||||
`Fetching deployment "${deployIdOrUrl}" in ${chalk.bold(contextName)}…`
|
||||
);
|
||||
} finally {
|
||||
output.stopSpinner();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} else 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;
|
||||
}
|
||||
|
||||
return deployment;
|
||||
}
|
||||
117
packages/cli/src/util/deploy/print-deployment-status.ts
Normal file
117
packages/cli/src/util/deploy/print-deployment-status.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import chalk from 'chalk';
|
||||
import type Client from '../client';
|
||||
import type { Deployment } from '@vercel-internals/types';
|
||||
import { getPreferredPreviewURL } from '../../util/deploy/get-preferred-preview-url';
|
||||
import { isDeploying } from '../../util/deploy/is-deploying';
|
||||
import linkStyle from '../output/link';
|
||||
import { prependEmoji, emoji } from '../../util/emoji';
|
||||
|
||||
export async function printDeploymentStatus(
|
||||
client: Client,
|
||||
{
|
||||
readyState,
|
||||
alias: aliasList,
|
||||
aliasError,
|
||||
target,
|
||||
indications,
|
||||
url: deploymentUrl,
|
||||
aliasWarning,
|
||||
}: {
|
||||
readyState: Deployment['readyState'];
|
||||
alias: string[];
|
||||
aliasError: Error;
|
||||
target: string;
|
||||
indications: any;
|
||||
url: string;
|
||||
aliasWarning?: {
|
||||
code: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
action?: string;
|
||||
};
|
||||
},
|
||||
deployStamp: () => string,
|
||||
noWait: boolean
|
||||
): Promise<number> {
|
||||
const { output } = client;
|
||||
|
||||
indications = indications || [];
|
||||
const isProdDeployment = target === 'production';
|
||||
|
||||
let isStillBuilding = false;
|
||||
if (noWait) {
|
||||
if (isDeploying(readyState)) {
|
||||
isStillBuilding = true;
|
||||
output.print(
|
||||
prependEmoji(
|
||||
'Note: Deployment is still processing...',
|
||||
emoji('notice')
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStillBuilding && readyState !== 'READY') {
|
||||
output.error(
|
||||
`Your deployment failed. Please retry later. More: https://err.sh/vercel/deployment-error`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (aliasError) {
|
||||
output.warn(
|
||||
`Failed to assign aliases${
|
||||
aliasError.message ? `: ${aliasError.message}` : ''
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
// print preview/production url
|
||||
let previewUrl: string;
|
||||
// if `noWait` is true, then use the deployment url, not an alias
|
||||
if (!noWait && Array.isArray(aliasList) && aliasList.length > 0) {
|
||||
const previewUrlInfo = await getPreferredPreviewURL(client, aliasList);
|
||||
if (previewUrlInfo) {
|
||||
previewUrl = previewUrlInfo.previewUrl;
|
||||
} else {
|
||||
previewUrl = `https://${deploymentUrl}`;
|
||||
}
|
||||
} else {
|
||||
// fallback to deployment url
|
||||
previewUrl = `https://${deploymentUrl}`;
|
||||
}
|
||||
|
||||
output.print(
|
||||
prependEmoji(
|
||||
`${isProdDeployment ? 'Production' : 'Preview'}: ${chalk.bold(
|
||||
previewUrl
|
||||
)} ${deployStamp()}`,
|
||||
emoji('success')
|
||||
) + `\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (aliasWarning?.message) {
|
||||
indications.push({
|
||||
type: 'warning',
|
||||
payload: aliasWarning.message,
|
||||
link: aliasWarning.link,
|
||||
action: aliasWarning.action,
|
||||
});
|
||||
}
|
||||
|
||||
const newline = '\n';
|
||||
for (let indication of indications) {
|
||||
const message =
|
||||
prependEmoji(chalk.dim(indication.payload), emoji(indication.type)) +
|
||||
newline;
|
||||
let link = '';
|
||||
if (indication.link)
|
||||
link =
|
||||
chalk.dim(
|
||||
`${indication.action || 'Learn More'}: ${linkStyle(indication.link)}`
|
||||
) + newline;
|
||||
output.print(message + link);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import mapCertError from './certs/map-cert-error';
|
||||
import toHost from './to-host';
|
||||
|
||||
/**
|
||||
* Retrieves a v13 deployment.
|
||||
* Retrieves a deployment by id or URL.
|
||||
*
|
||||
* @param client - The Vercel CLI client instance.
|
||||
* @param contextName - The scope context/team name.
|
||||
|
||||
@@ -1,124 +1,75 @@
|
||||
import chalk from 'chalk';
|
||||
import type Client from '../client';
|
||||
import type { Deployment, Project, Team } from '@vercel-internals/types';
|
||||
import type { Project } from '@vercel-internals/types';
|
||||
import { getCommandName } from '../pkg-name';
|
||||
import getDeployment from '../get-deployment';
|
||||
import { getDeploymentByIdOrURL } from '../deploy/get-deployment-by-id-or-url';
|
||||
import getScope from '../get-scope';
|
||||
import getTeamById from '../teams/get-team-by-id';
|
||||
import { isValidName } from '../is-valid-name';
|
||||
import { isErrnoException } from '@vercel/error-utils';
|
||||
import ms from 'ms';
|
||||
import rollbackStatus from './status';
|
||||
|
||||
/**
|
||||
* Requests a rollback and waits for it complete.
|
||||
* @param {Client} client - The Vercel client instance
|
||||
* @param {string} deployId - The deployment name or id to rollback
|
||||
* @param {string} deployIdOrUrl - The deployment name or id to rollback
|
||||
* @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 requestRollback({
|
||||
client,
|
||||
deployId,
|
||||
deployIdOrUrl,
|
||||
project,
|
||||
timeout,
|
||||
}: {
|
||||
client: Client;
|
||||
deployId: string;
|
||||
deployIdOrUrl: string;
|
||||
project: Project;
|
||||
timeout?: string;
|
||||
}): Promise<number> {
|
||||
const { config, output } = client;
|
||||
const { output } = client;
|
||||
const { contextName } = await getScope(client);
|
||||
|
||||
if (!isValidName(deployId)) {
|
||||
output.error(
|
||||
`The provided argument "${deployId}" is not a valid deployment or project`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let deployment: Deployment;
|
||||
let team: Team | undefined;
|
||||
|
||||
try {
|
||||
output.spinner(
|
||||
`Fetching deployment "${deployId}" in ${chalk.bold(contextName)}…`
|
||||
);
|
||||
const deployment = await getDeploymentByIdOrURL({
|
||||
client,
|
||||
contextName,
|
||||
deployIdOrUrl,
|
||||
});
|
||||
|
||||
const [teamResult, deploymentResult] = await Promise.allSettled([
|
||||
config.currentTeam ? getTeamById(client, config.currentTeam) : undefined,
|
||||
getDeployment(client, contextName, deployId),
|
||||
]);
|
||||
// create the rollback
|
||||
await client.fetch(`/v9/projects/${project.id}/rollback/${deployment.id}`, {
|
||||
body: {}, // required
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (teamResult.status === 'rejected') {
|
||||
output.error(`Failed to retrieve team information: ${teamResult.reason}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (deploymentResult.status === 'rejected') {
|
||||
output.error(deploymentResult.reason);
|
||||
return 1;
|
||||
}
|
||||
|
||||
team = teamResult.value;
|
||||
deployment = deploymentResult.value;
|
||||
|
||||
// re-render the spinner text because it goes so fast
|
||||
output.log(
|
||||
`Fetching deployment "${deployId}" in ${chalk.bold(contextName)}…`
|
||||
);
|
||||
} finally {
|
||||
output.stopSpinner();
|
||||
}
|
||||
|
||||
if (deployment.team?.id) {
|
||||
if (!team || deployment.team.id !== team.id) {
|
||||
output.error(
|
||||
team
|
||||
? `Deployment doesn't belong to current team ${chalk.bold(
|
||||
contextName
|
||||
)}`
|
||||
: `Deployment belongs to a different team`
|
||||
if (timeout !== undefined && ms(timeout) === 0) {
|
||||
output.log(
|
||||
`Successfully requested rollback of ${chalk.bold(project.name)} to ${
|
||||
deployment.url
|
||||
} (${deployment.id})`
|
||||
);
|
||||
output.log(
|
||||
`To check rollback status, run ${getCommandName('rollback')}.`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// check the status
|
||||
return await rollbackStatus({
|
||||
client,
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
timeout,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
output.prettyError(err);
|
||||
if (isErrnoException(err) && err.code === 'ERR_INVALID_TEAM') {
|
||||
output.error(
|
||||
`Use ${chalk.bold('vc switch')} to change your current team`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
} else if (team) {
|
||||
output.error(
|
||||
`Deployment doesn't belong to current team ${chalk.bold(contextName)}`
|
||||
);
|
||||
output.error(`Use ${chalk.bold('vc switch')} to change your current team`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// create the rollback
|
||||
await client.fetch<any>(
|
||||
`/v9/projects/${project.id}/rollback/${deployment.id}`,
|
||||
{
|
||||
body: {}, // required
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
|
||||
if (timeout !== undefined && ms(timeout) === 0) {
|
||||
output.log(
|
||||
`Successfully requested rollback of ${chalk.bold(project.name)} to ${
|
||||
deployment.url
|
||||
} (${deployment.id})`
|
||||
);
|
||||
output.log(`To check rollback status, run ${getCommandName('rollback')}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// check the status
|
||||
return await rollbackStatus({
|
||||
client,
|
||||
contextName,
|
||||
deployment,
|
||||
project,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
1
packages/cli/test/fixtures/unit/commands/redeploy/simple-static/index.html
vendored
Normal file
1
packages/cli/test/fixtures/unit/commands/redeploy/simple-static/index.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -8,7 +8,7 @@ import { formatProvider } from '../../src/util/git/connect-git-provider';
|
||||
import { parseEnvironment } from '../../src/commands/pull';
|
||||
import type { Env } from '@vercel/build-utils';
|
||||
|
||||
const envs: ProjectEnvVariable[] = [
|
||||
export const envs: ProjectEnvVariable[] = [
|
||||
{
|
||||
type: 'encrypted',
|
||||
id: '781dt89g8r2h789g',
|
||||
@@ -101,34 +101,12 @@ const systemEnvs = [
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultProject = {
|
||||
export const defaultProject: Project = {
|
||||
id: 'foo',
|
||||
name: 'cli',
|
||||
accountId: 'K4amb7K9dAt5R2vBJWF32bmY',
|
||||
createdAt: 1555413045188,
|
||||
updatedAt: 1555413045188,
|
||||
env: envs,
|
||||
targets: {
|
||||
production: {
|
||||
alias: ['foobar.com'],
|
||||
aliasAssigned: 1571239348998,
|
||||
createdAt: 1571239348998,
|
||||
createdIn: 'sfo1',
|
||||
deploymentHostname: 'a-project-name-rjtr4pz3f',
|
||||
forced: false,
|
||||
id: 'dpl_89qyp1cskzkLrVicDaZoDbjyHuDJ',
|
||||
meta: {},
|
||||
plan: 'pro',
|
||||
private: true,
|
||||
readyState: 'READY',
|
||||
requestedAt: 1571239348998,
|
||||
target: 'production',
|
||||
teamId: null,
|
||||
type: 'LAMBDAS',
|
||||
url: 'a-project-name-rjtr4pz3f.vercel.app',
|
||||
userId: 'K4amb7K9dAt5R2vBJWF32bmY',
|
||||
},
|
||||
},
|
||||
latestDeployments: [
|
||||
{
|
||||
alias: ['foobar.com'],
|
||||
@@ -136,18 +114,18 @@ export const defaultProject = {
|
||||
buildingAt: 1571239348998,
|
||||
createdAt: 1571239348998,
|
||||
createdIn: 'sfo1',
|
||||
creator: {
|
||||
uid: 'K4amb7K9dAt5R2vBJWF32bmY',
|
||||
},
|
||||
forced: false,
|
||||
id: 'dpl_89qyp1cskzkLrVicDaZoDbjyHuDJ',
|
||||
meta: {},
|
||||
plan: 'pro',
|
||||
private: true,
|
||||
readyState: 'READY',
|
||||
requestedAt: 1571239348998,
|
||||
target: 'production',
|
||||
teamId: null,
|
||||
type: undefined,
|
||||
url: 'a-project-name-rjtr4pz3f.vercel.app',
|
||||
userId: 'K4amb7K9dAt5R2vBJWF32bmY',
|
||||
},
|
||||
],
|
||||
lastRollbackTarget: null,
|
||||
@@ -223,7 +201,10 @@ export function useUnknownProject() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(project: Partial<Project> = defaultProject) {
|
||||
export function useProject(
|
||||
project: Partial<Project> = defaultProject,
|
||||
projectEnvs: ProjectEnvVariable[] = envs
|
||||
) {
|
||||
client.scenario.get(`/v8/projects/${project.name}`, (_req, res) => {
|
||||
res.json(project);
|
||||
});
|
||||
@@ -241,7 +222,6 @@ export function useProject(project: Partial<Project> = defaultProject) {
|
||||
typeof req.params.target === 'string'
|
||||
? parseEnvironment(req.params.target)
|
||||
: undefined;
|
||||
let projectEnvs = envs;
|
||||
if (target) {
|
||||
projectEnvs = projectEnvs.filter(env => {
|
||||
if (!env.target) return false;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { parse } from 'dotenv';
|
||||
import env from '../../../src/commands/env';
|
||||
import { setupUnitFixture } from '../../helpers/setup-unit-fixture';
|
||||
import { client } from '../../mocks/client';
|
||||
import { defaultProject, useProject } from '../../mocks/project';
|
||||
import { defaultProject, envs, useProject } from '../../mocks/project';
|
||||
import { useTeams } from '../../mocks/team';
|
||||
import { useUser } from '../../mocks/user';
|
||||
|
||||
@@ -308,21 +308,26 @@ describe('env', () => {
|
||||
try {
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
defaultProject.env.push({
|
||||
type: 'encrypted',
|
||||
id: '781dt89g8r2h789g',
|
||||
key: 'NEW_VAR',
|
||||
value: '"testvalue"',
|
||||
target: ['development'],
|
||||
configurationId: null,
|
||||
updatedAt: 1557241361455,
|
||||
createdAt: 1557241361455,
|
||||
});
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'env-pull-delta-quotes',
|
||||
name: 'env-pull-delta-quotes',
|
||||
});
|
||||
useProject(
|
||||
{
|
||||
...defaultProject,
|
||||
id: 'env-pull-delta-quotes',
|
||||
name: 'env-pull-delta-quotes',
|
||||
},
|
||||
[
|
||||
...envs,
|
||||
{
|
||||
type: 'encrypted',
|
||||
id: '781dt89g8r2h789g',
|
||||
key: 'NEW_VAR',
|
||||
value: '"testvalue"',
|
||||
target: ['development'],
|
||||
configurationId: null,
|
||||
updatedAt: 1557241361455,
|
||||
createdAt: 1557241361455,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
client.setArgv('env', 'pull', '--yes', '--cwd', cwd);
|
||||
const pullPromise = env(client);
|
||||
@@ -336,7 +341,6 @@ describe('env', () => {
|
||||
} finally {
|
||||
client.setArgv('env', 'rm', 'NEW_VAR', '--yes', '--cwd', cwd);
|
||||
await env(client);
|
||||
defaultProject.env.pop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -345,21 +349,26 @@ describe('env', () => {
|
||||
try {
|
||||
useUser();
|
||||
useTeams('team_dummy');
|
||||
defaultProject.env.push({
|
||||
type: 'encrypted',
|
||||
id: '781dt89g8r2h789g',
|
||||
key: 'NEW_VAR',
|
||||
value: 'testvalue',
|
||||
target: ['development'],
|
||||
configurationId: null,
|
||||
updatedAt: 1557241361455,
|
||||
createdAt: 1557241361455,
|
||||
});
|
||||
useProject({
|
||||
...defaultProject,
|
||||
id: 'env-pull-delta-quotes',
|
||||
name: 'env-pull-delta-quotes',
|
||||
});
|
||||
useProject(
|
||||
{
|
||||
...defaultProject,
|
||||
id: 'env-pull-delta-quotes',
|
||||
name: 'env-pull-delta-quotes',
|
||||
},
|
||||
[
|
||||
...envs,
|
||||
{
|
||||
type: 'encrypted',
|
||||
id: '781dt89g8r2h789g',
|
||||
key: 'NEW_VAR',
|
||||
value: 'testvalue',
|
||||
target: ['development'],
|
||||
configurationId: null,
|
||||
updatedAt: 1557241361455,
|
||||
createdAt: 1557241361455,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
client.setArgv('env', 'pull', '.env.testquotes', '--yes', '--cwd', cwd);
|
||||
const pullPromise = env(client);
|
||||
@@ -373,7 +382,6 @@ describe('env', () => {
|
||||
} finally {
|
||||
client.setArgv('env', 'rm', 'NEW_VAR', '--yes', '--cwd', cwd);
|
||||
await env(client);
|
||||
defaultProject.env.pop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
103
packages/cli/test/unit/commands/redeploy.test.ts
Normal file
103
packages/cli/test/unit/commands/redeploy.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { client } from '../../mocks/client';
|
||||
import { defaultProject, useProject } from '../../mocks/project';
|
||||
import redeploy from '../../../src/commands/redeploy';
|
||||
import { setupUnitFixture } from '../../helpers/setup-unit-fixture';
|
||||
import { useDeployment } from '../../mocks/deployment';
|
||||
import { useTeams } from '../../mocks/team';
|
||||
import { useUser } from '../../mocks/user';
|
||||
|
||||
describe('redeploy', () => {
|
||||
it('should error if missing deployment url', async () => {
|
||||
client.setArgv('redeploy');
|
||||
const exitCodePromise = redeploy(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
'Missing required deployment id or url:'
|
||||
);
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if deployment not found', async () => {
|
||||
initRedeployTest();
|
||||
client.setArgv('redeploy', 'foo');
|
||||
const exitCodePromise = redeploy(client);
|
||||
|
||||
await expect(client.stderr).toOutput('Fetching deployment "foo" in ');
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: Can\'t find the deployment "foo" under the context'
|
||||
);
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error if deployment belongs to another team', async () => {
|
||||
const { fromDeployment } = initRedeployTest();
|
||||
fromDeployment.team = {
|
||||
id: 'abc',
|
||||
name: 'abc',
|
||||
slug: 'abc',
|
||||
};
|
||||
client.setArgv('rollback', fromDeployment.id);
|
||||
const exitCodePromise = redeploy(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${fromDeployment.id}" in ${fromDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: Deployment belongs to a different team'
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should redeploy an existing deployment', async () => {
|
||||
const { fromDeployment } = initRedeployTest();
|
||||
client.setArgv('rollback', fromDeployment.id);
|
||||
|
||||
const exitCodePromise = redeploy(client);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${fromDeployment.id}" in ${fromDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput('Production');
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
|
||||
it('should redeploy and not wait for completion', async () => {
|
||||
const { fromDeployment, toDeployment } = initRedeployTest();
|
||||
toDeployment.readyState = 'QUEUED';
|
||||
client.setArgv('rollback', fromDeployment.id, '--no-wait');
|
||||
|
||||
const exitCodePromise = redeploy(client);
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${fromDeployment.id}" in ${fromDeployment.creator?.username}`
|
||||
);
|
||||
await expect(client.stderr).toOutput(
|
||||
'Note: Deployment is still processing'
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
function initRedeployTest() {
|
||||
setupUnitFixture('commands/redeploy/simple-static');
|
||||
const user = useUser();
|
||||
useTeams('team_dummy');
|
||||
const { project } = useProject({
|
||||
...defaultProject,
|
||||
id: 'vercel-redeploy',
|
||||
name: 'vercel-redeploy',
|
||||
});
|
||||
const fromDeployment = useDeployment({ creator: user });
|
||||
const toDeployment = useDeployment({ creator: user });
|
||||
|
||||
client.scenario.post(`/v13/deployments`, (req, res) => {
|
||||
res.json(toDeployment);
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
fromDeployment,
|
||||
toDeployment,
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('rollback', () => {
|
||||
|
||||
await expect(client.stderr).toOutput('Retrieving project…');
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: The provided argument "????" is not a valid deployment or project'
|
||||
'Error: The provided argument "????" is not a valid deployment ID or URL'
|
||||
);
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
@@ -51,9 +51,8 @@ describe('rollback', () => {
|
||||
const exitCodePromise = rollback(client);
|
||||
|
||||
await expect(client.stderr).toOutput('Retrieving project…');
|
||||
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'
|
||||
'Error: Can\'t find the deployment "foo" under the context'
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
@@ -147,14 +146,14 @@ describe('rollback', () => {
|
||||
});
|
||||
|
||||
client.setArgv('rollback', previousDeployment.id, '--yes', '--cwd', cwd);
|
||||
const exitCodePromise = rollback(client);
|
||||
const exitCode = await rollback(client);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
await expect(client.stderr).toOutput('Retrieving project…');
|
||||
await expect(client.stderr).toOutput(
|
||||
`Fetching deployment "${previousDeployment.id}" in ${previousDeployment.creator?.username}`
|
||||
);
|
||||
|
||||
await expect(exitCodePromise).rejects.toThrow('Response Error (500)');
|
||||
await expect(client.stderr).toOutput('Response Error (500)');
|
||||
});
|
||||
|
||||
it('should error if rollback fails (no aliases)', async () => {
|
||||
@@ -222,7 +221,7 @@ describe('rollback', () => {
|
||||
'--cwd',
|
||||
cwd,
|
||||
'--timeout',
|
||||
'2s'
|
||||
'1s'
|
||||
);
|
||||
const exitCodePromise = rollback(client);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import buildCreateDeployment from './create-deployment';
|
||||
|
||||
export { checkDeploymentStatus } from './check-deployment-status';
|
||||
export { getVercelIgnore, buildFileTree } from './utils/index';
|
||||
export const createDeployment = buildCreateDeployment();
|
||||
export * from './errors';
|
||||
|
||||
Reference in New Issue
Block a user