From b439d63fc80af0f1f9a96355bf11f8ba6ff4da04 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Thu, 14 May 2026 19:20:40 +0530 Subject: [PATCH 01/10] CL-1753 | Add instant rollback feature support for Launch CLI --- .talismanrc | 2 + src/commands/launch/rollback.test.ts | 233 ++++++++++++++++++ src/commands/launch/rollback.ts | 351 +++++++++++++++++++++++++++ src/graphql/mutation.ts | 10 + src/graphql/queries.ts | 11 +- 5 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/commands/launch/rollback.test.ts create mode 100644 src/commands/launch/rollback.ts diff --git a/.talismanrc b/.talismanrc index fb07040..70638de 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,4 +6,6 @@ fileignoreconfig: checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62 - filename: package-lock.json checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 +- filename: src/commands/launch/rollback.test.ts + checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd version: "1.0" \ No newline at end of file diff --git a/src/commands/launch/rollback.test.ts b/src/commands/launch/rollback.test.ts new file mode 100644 index 00000000..9508fec --- /dev/null +++ b/src/commands/launch/rollback.test.ts @@ -0,0 +1,233 @@ +import Rollback from './rollback'; +import { Logger } from '../../util'; +import { cliux } from '@contentstack/cli-utilities'; + +jest.mock('../../util', () => { + const actual = jest.requireActual('../../util'); + return { + ...actual, + Logger: jest.fn(), + selectOrg: jest.fn(), + selectProject: jest.fn(), + }; +}); + +jest.mock('@contentstack/cli-utilities', () => { + const actual = jest.requireActual('@contentstack/cli-utilities'); + return { + ...actual, + configHandler: { + get: jest.fn((key) => { + if (key === 'authtoken') return 'dummy-token'; + if (key === 'authorisationType') return 'OAuth'; + if (key === 'oauthAccessToken') return 'dummy-oauth-token'; + return undefined; + }), + }, + cliux: { + ...actual.cliux, + inquire: jest.fn(), + print: jest.fn(), + }, + }; +}); + +const targetDeployment = { + uid: 'target-uid', + status: 'ARCHIVED', + gitBranch: 'main', + commitHash: 'abcdef1', + createdAt: '2026-04-29T00:00:00Z', + commitMessage: 'previous good build', + deploymentUrl: 'https://example.com', + deploymentNumber: 2, + isRollbackEligible: true, +}; + +const liveDeployment = { + ...targetDeployment, + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, +}; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { + edges: [ + { node: liveDeployment }, + { node: targetDeployment }, + ], + }, + }, + }, + ], + }, + }, +}; + +const buildCommand = (flags: Record = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => { + const cmd = new Rollback([], {} as any); + (cmd as any).flags = flags; + (cmd as any).log = jest.fn(); + (cmd as any).logger = { log: jest.fn() }; + (cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } }; + (cmd as any).apolloClient = { + query: queryImpl || jest.fn(), + mutate: mutateImpl || jest.fn(), + }; + return cmd; +}; + +describe('Rollback Command', () => { + let exitMock: jest.SpyInstance; + + beforeEach(() => { + (Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() })); + exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('exits when no rollback-eligible deployments are available', async () => { + const noEligibleResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { edges: [{ node: liveDeployment }] }, + }, + }, + ], + }, + }, + }; + const query = jest.fn().mockResolvedValueOnce(noEligibleResponse); + const mutate = jest.fn(); + const cmd = buildCommand({ environment: 'Default' }, query, mutate); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'No rollback-eligible deployments are available for this environment.', + 'error', + ); + }); + + it('exits when --deployment flag does not match an eligible deployment', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'unknown-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Provided deployment UID is not rollback-eligible or does not exist.', + 'error', + ); + }); + + it('skips the mutation when the user does not confirm', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'audit' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt + + await (cmd as any).rollbackDeployment(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('fires the rollback mutation and prints the success message', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn().mockResolvedValueOnce({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'restoring' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + await (cmd as any).rollbackDeployment(); + + expect(mutate).toHaveBeenCalledTimes(1); + const variables = mutate.mock.calls[0][0].variables; + expect(variables).toEqual({ + input: { + deployment: 'target-uid', + environment: 'env-uid', + reason: 'restoring', + }, + }); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('logs an error and exits when the rollback mutation fails', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const error = Object.assign(new Error('boom'), { + graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }], + }); + const mutate = jest.fn().mockRejectedValueOnce(error); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock) + .mockResolvedValueOnce('') // reason + .mockResolvedValueOnce(true); // confirm + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Rollback failed. Please try again. (DeploymentRollbackFailed)', + 'error', + ); + }); +}); diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts new file mode 100644 index 00000000..51b9b37 --- /dev/null +++ b/src/commands/launch/rollback.ts @@ -0,0 +1,351 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import filter from 'lodash/filter'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { + environmentsQuery, + latestLiveDeploymentQuery, + rollbackDeploymentMutation, +} from '../../graphql'; +import { Logger, selectOrg, selectProject } from '../../util'; + +export default class Rollback extends BaseCommand { + static description = 'Roll back to previous deployment'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + // eslint-disable-next-line max-len + '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment= --org= --project= --reason="restoring previous build"', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + environment: Flags.string({ + char: 'e', + description: 'Environment name or UID', + }), + deployment: Flags.string({ + description: '[Optional] Deployment UID to roll back to', + }), + reason: Flags.string({ + description: '[Optional] Reason for the rollback (saved to audit log)', + }), + }; + + async init(): Promise { + await super.init(); + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + await this.prepareApiClients(); + } + + async run(): Promise { + if (!this.flags.environment) { + await this.getConfig(); + } + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.rollbackDeployment(); + } + + /** + * @method rollbackDeployment - resolve env, run select + review steps, fire mutation + * + * @memberof Rollback + */ + async rollbackDeployment(): Promise { + const environment = await this.resolveEnvironment(); + const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); + const eligibleSorted = this.getEligibleSortedDeployments(environment, currentLive?.uid); + + if (isEmpty(eligibleSorted)) { + this.log('No rollback-eligible deployments are available for this environment.', 'error'); + process.exit(1); + } + + this.printSelectStep(environment, currentLive, eligibleSorted); + const target = await this.selectDeployment(eligibleSorted); + + this.printReviewStep(currentLive, target, eligibleSorted); + const reason = await this.promptReason(); + const confirmed = await ux.inquire({ + type: 'confirm', + name: 'confirm', + message: 'Confirm & Rollback?', + }); + + if (!confirmed) { + ux.print(chalk.yellow('Rollback aborted.')); + return; + } + + try { + await this.apolloClient.mutate({ + mutation: rollbackDeploymentMutation, + variables: { + input: { + deployment: target.uid, + environment: environment.uid, + ...(reason ? { reason } : {}), + }, + }, + }); + } catch (error: unknown) { + const err = error as { graphQLErrors?: { extensions?: { exception?: { name?: string } } }[]; message?: string }; + const code = err?.graphQLErrors?.[0]?.extensions?.exception?.name || err?.message; + this.log(`Rollback failed. Please try again. (${code})`, 'error'); + process.exit(1); + } + + ux.print(''); + ux.print( + `Promoting deployment ${chalk.cyan(`#${target.deploymentNumber}`)} ` + + chalk.dim(`(${target.uid})`) + '…', + ); + + ux.print(''); + ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); + const label = `${chalk.cyan(`#${target.deploymentNumber}`)} ${chalk.dim(`(${target.uid})`)}`; + ux.print(` Deployment ${label} is now ${chalk.green('LIVE')}.`); + ux.print(''); + } + + /** + * @method resolveEnvironment - resolve environment via flag, config, or prompt + * + * @memberof Rollback + */ + async resolveEnvironment(): Promise { + const environments = await this.apolloClient + .query({ + query: environmentsQuery, + variables: { skipRollbackData: false }, + }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + + if (this.flags.environment) { + const environment = find( + environments, + ({ uid, name }) => uid === this.flags.environment || name === this.flags.environment, + ); + if (isEmpty(environment)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } + return environment; + } + + // NOTE: rollback is destructive; never auto-select from saved config — always prompt. + return ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); + } + + /** + * @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment + * + * @memberof Rollback + */ + async fetchCurrentLiveDeployment(environmentUid: string): Promise { + return this.apolloClient + .query({ + query: latestLiveDeploymentQuery, + variables: { query: { environment: environmentUid } }, + }) + .then(({ data }) => data?.latestLiveDeployment) + .catch(() => undefined); + } + + /** + * @method getEligibleSortedDeployments - eligible deployments excluding current live, sorted by number desc + * + * @memberof Rollback + */ + getEligibleSortedDeployments(environment: any, currentLiveUid?: string): any[] { + const deployments = map(environment?.deployments?.edges, 'node'); + const eligible = filter( + deployments, + (d) => d.isRollbackEligible && d.uid !== currentLiveUid, + ); + return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0)); + } + + /** + * @method selectDeployment - resolve target via --deployment flag or interactive picker + * + * @memberof Rollback + */ + async selectDeployment(eligibleSorted: any[]): Promise { + if (this.flags.deployment) { + const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment); + if (isEmpty(match)) { + this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error'); + process.exit(1); + } + return match; + } + + const choices = map(eligibleSorted, (d) => { + const message = (d.commitMessage || '').split('\n')[0].trim() || '—'; + const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message; + return { + ...d, + name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`, + value: d.uid, + }; + }); + + const selectedUid = await ux.inquire({ + type: 'search-list', + name: 'Deployment', + choices, + message: 'Select a version to restore', + }); + + return find(eligibleSorted, { uid: selectedUid }) as Record; + } + + /** + * @method promptReason - prompt for rollback reason unless provided via --reason flag + * + * @memberof Rollback + */ + async promptReason(): Promise { + if (this.flags.reason) { + return this.flags.reason.trim() || undefined; + } + const input = await ux.inquire({ + type: 'input', + name: 'reason', + message: 'Reason (saved to audit log) — press enter to skip:', + }); + const trimmed = (input || '').trim(); + return trimmed ? trimmed : undefined; + } + + /** + * @method printSelectStep - mirror the UI "select" step heading and table + * + * @memberof Rollback + */ + printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Roll back to previous deployment')); + ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`); + ux.print(''); + ux.print(chalk.bold('Currently live')); + ux.print(` ${formatDeployment(currentLive)}`); + ux.print(''); + ux.print(chalk.bold('Select a version to restore')); + ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.')); + const count = eligibleSorted.length; + ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`)); + ux.print(''); + } + + /** + * @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary + * + * @memberof Rollback + */ + printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Review rollback')); + ux.print(''); + ux.print('You are about to replace your live site with the version below.'); + ux.print('This build will be pushed to the edge immediately.'); + ux.print(''); + ux.print( + `${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`, + ); + ux.print(' associated with the selected deployment.'); + + const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid); + const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : []; + if (skipped.length > 0) { + const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', '); + const noun = skipped.length === 1 ? 'good deployment' : 'good deployments'; + const verb = skipped.length === 1 ? 'stays' : 'stay'; + ux.print(''); + ux.print( + `${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun} — ${list}`, + ); + ux.print(` ${verb} in history and can be restored later.`); + } + + ux.print(''); + ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`); + ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`); + ux.print(''); + ux.print( + chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'), + ); + ux.print(''); + } +} + +function shortHash(hash?: string): string { + return hash ? hash.substring(0, 7) : ''; +} + +function sourceLabel(deployment?: any): string { + if (!deployment) { + return ''; + } + const hash = shortHash(deployment.commitHash); + if (deployment.gitBranch && hash) { + return `${deployment.gitBranch} - ${hash}`; + } + return deployment.gitBranch || hash || ''; +} + +function formatDeployment(deployment?: any): string { + if (!deployment) { + return chalk.dim('(none)'); + } + const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; + const source = sourceLabel(deployment); + const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim(); + const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message; + const createdAt = deployment.createdAt || ''; + const numberCol = chalk.green(number.padEnd(6)); + const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22); + const messageCol = truncated || chalk.dim('—'); + return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`; +} diff --git a/src/graphql/mutation.ts b/src/graphql/mutation.ts index 97bc3f6..431ef2f 100755 --- a/src/graphql/mutation.ts +++ b/src/graphql/mutation.ts @@ -76,8 +76,18 @@ const importProjectMutation: DocumentNode = gql` } `; +const rollbackDeploymentMutation: DocumentNode = gql` + mutation RollbackDeployment($input: RollbackDeploymentInput!) { + rollbackDeployment(input: $input) { + status + environmentUid + } + } +`; + export { importProjectMutation, createDeploymentMutation, + rollbackDeploymentMutation, createSignedUploadUrlMutation, }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index c27debe..1cb0e81 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -145,12 +145,17 @@ const latestLiveDeploymentQuery: DocumentNode = gql` environment deploymentNumber deploymentUrl + status + gitBranch + commitHash + commitMessage + createdAt } } `; const environmentsQuery: DocumentNode = gql` - query Environments { + query Environments($skipRollbackData: Boolean = true) { Environments { edges { node { @@ -165,6 +170,10 @@ const environmentsQuery: DocumentNode = gql` commitMessage deploymentUrl deploymentNumber + status @skip(if: $skipRollbackData) + gitBranch @skip(if: $skipRollbackData) + commitHash @skip(if: $skipRollbackData) + isRollbackEligible @skip(if: $skipRollbackData) } } } From 34d776121dbc9e7c97e0592f062eff497be6dd55 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Fri, 15 May 2026 12:56:06 +0530 Subject: [PATCH 02/10] CL-1753 | Add unit tests for Rollback command in Launch CLI --- .talismanrc | 2 + test/unit/commands/rollback.test.ts | 209 ++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 test/unit/commands/rollback.test.ts diff --git a/.talismanrc b/.talismanrc index 70638de..f0cd810 100644 --- a/.talismanrc +++ b/.talismanrc @@ -8,4 +8,6 @@ fileignoreconfig: checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 - filename: src/commands/launch/rollback.test.ts checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd +- filename: test/unit/commands/rollback.test.ts + checksum: d1f931f2d9a397131409399ad6463653e28b5a2224e870b641d9ba57c4418f18 version: "1.0" \ No newline at end of file diff --git a/test/unit/commands/rollback.test.ts b/test/unit/commands/rollback.test.ts new file mode 100644 index 00000000..ed03300 --- /dev/null +++ b/test/unit/commands/rollback.test.ts @@ -0,0 +1,209 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import type { ApolloClient } from '@apollo/client/core'; +import Rollback from '../../../src/commands/launch/rollback'; +import { cliux } from '@contentstack/cli-utilities'; +import { testFlags } from '../mock'; +import sinon, { stub } from 'sinon'; +import { config } from 'dotenv'; +import * as commonUtility from '../../../src/util/common-utility'; +import { BaseCommand } from '../../../src/base-command'; + +config(); + +const orgUid = process.env.ORG || 'test-org-uid'; +const projectUid = process.env.PROJECT || 'test-project-uid'; +const environmentName = process.env.ENVIRONMENT || 'Default'; +const targetDeploymentUid = process.env.ROLLBACK_DEPLOYMENT || 'target-deployment-uid'; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: environmentName, + deployments: { + edges: [ + { + node: { + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, + isRollbackEligible: true, + }, + }, + { + node: { + uid: targetDeploymentUid, + status: 'ARCHIVED', + deploymentNumber: 2, + isRollbackEligible: true, + }, + }, + ], + }, + }, + }, + ], + }, + }, +}; + +const projectsResponse = { + data: { + projects: { + edges: [{ node: { uid: projectUid, name: 'Test Project' } }], + }, + }, +}; + +const getFlagValue = (flag: unknown): string | undefined => { + if (flag === undefined || flag === null) { + return undefined; + } + return String(flag); +}; + +const createApolloClientStub = () => ({ + query: stub().callsFake(({ query }) => { + const queryBody = query?.loc?.source?.body ?? ''; + if (queryBody.includes('projects')) { + return Promise.resolve(projectsResponse); + } + if (queryBody.includes('Environments')) { + return Promise.resolve(environmentsResponse); + } + return Promise.resolve({ data: {} }); + }), + mutate: stub().resolves({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }), +}); + +describe('Rollback', () => { + let rollbackDeploymentStub: sinon.SinonStub; + + beforeEach(() => { + stub(commonUtility, 'selectOrg').callsFake(async ({ config, flags }) => { + const orgFlag = getFlagValue(flags.org); + if (orgFlag) { + config.currentConfig.organizationUid = + orgFlag === testFlags.invalidOrg.uid ? testFlags.invalidOrg.uid : orgUid; + return; + } + config.currentConfig.organizationUid = orgUid; + }); + stub(commonUtility, 'selectProject').callsFake(async ({ config, flags }) => { + const projectFlag = getFlagValue(flags?.project) ?? config?.project; + if (projectFlag && projectFlag !== testFlags.invalidProj) { + config.currentConfig.uid = projectUid; + return; + } + if (!config.currentConfig.uid) { + await cliux.inquire({ + type: 'search-list', + name: 'Project', + message: 'Choose a project', + }); + config.currentConfig.uid = projectUid; + } + }); + stub(BaseCommand.prototype, 'prepareApiClients').callsFake(async function (this: BaseCommand) { + this.apolloClient = createApolloClientStub() as unknown as ApolloClient; + this.apolloLogsClient = {} as unknown as ApolloClient; + }); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('Should run the command when all the flags are passed', async function () { + const args = [ + '--org', + orgUid, + '-e', + environmentName, + '--project', + projectUid, + '--deployment', + targetDeploymentUid, + ]; + const inquireStub = stub(cliux, 'inquire'); + + await Rollback.run(args); + + sinon.assert.calledOnce(rollbackDeploymentStub); + sinon.assert.notCalled(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for org when org flag is not passed', async function () { + const args = ['-e', environmentName, '--project', projectUid]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when project flag is not passed', async function () { + const args = ['-e', environmentName, '--org', orgUid]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); + + it('Should ask for environment when environment flag is not passed', async function () { + rollbackDeploymentStub.restore(); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').callsFake(async function (this: Rollback) { + await this.resolveEnvironment(); + }); + + const args = ['--org', orgUid, '--project', projectUid]; + const inquireStub = stub(cliux, 'inquire').resolves(environmentName); + + await Rollback.run(args); + + sinon.assert.called(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for organization with a warning when passed incorrect org uid', async function () { + const args = ['--org', testFlags.invalidOrg.uid, '--project', projectUid, '-e', environmentName]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when passed incorrect project name', async function () { + const args = ['--org', orgUid, '--project', testFlags.invalidProj, '-e', environmentName]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); +}); From b04a2649272c424ffa06b70752cd37abe79331fd Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Thu, 14 May 2026 19:20:40 +0530 Subject: [PATCH 03/10] feat: add rollback command for instantly rolling back to previous deployments --- .talismanrc | 2 + src/commands/launch/rollback.test.ts | 233 ++++++++++++++++++ src/commands/launch/rollback.ts | 351 +++++++++++++++++++++++++++ src/graphql/mutation.ts | 10 + src/graphql/queries.ts | 11 +- 5 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/commands/launch/rollback.test.ts create mode 100644 src/commands/launch/rollback.ts diff --git a/.talismanrc b/.talismanrc index fb07040..70638de 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,4 +6,6 @@ fileignoreconfig: checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62 - filename: package-lock.json checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 +- filename: src/commands/launch/rollback.test.ts + checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd version: "1.0" \ No newline at end of file diff --git a/src/commands/launch/rollback.test.ts b/src/commands/launch/rollback.test.ts new file mode 100644 index 00000000..9508fec --- /dev/null +++ b/src/commands/launch/rollback.test.ts @@ -0,0 +1,233 @@ +import Rollback from './rollback'; +import { Logger } from '../../util'; +import { cliux } from '@contentstack/cli-utilities'; + +jest.mock('../../util', () => { + const actual = jest.requireActual('../../util'); + return { + ...actual, + Logger: jest.fn(), + selectOrg: jest.fn(), + selectProject: jest.fn(), + }; +}); + +jest.mock('@contentstack/cli-utilities', () => { + const actual = jest.requireActual('@contentstack/cli-utilities'); + return { + ...actual, + configHandler: { + get: jest.fn((key) => { + if (key === 'authtoken') return 'dummy-token'; + if (key === 'authorisationType') return 'OAuth'; + if (key === 'oauthAccessToken') return 'dummy-oauth-token'; + return undefined; + }), + }, + cliux: { + ...actual.cliux, + inquire: jest.fn(), + print: jest.fn(), + }, + }; +}); + +const targetDeployment = { + uid: 'target-uid', + status: 'ARCHIVED', + gitBranch: 'main', + commitHash: 'abcdef1', + createdAt: '2026-04-29T00:00:00Z', + commitMessage: 'previous good build', + deploymentUrl: 'https://example.com', + deploymentNumber: 2, + isRollbackEligible: true, +}; + +const liveDeployment = { + ...targetDeployment, + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, +}; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { + edges: [ + { node: liveDeployment }, + { node: targetDeployment }, + ], + }, + }, + }, + ], + }, + }, +}; + +const buildCommand = (flags: Record = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => { + const cmd = new Rollback([], {} as any); + (cmd as any).flags = flags; + (cmd as any).log = jest.fn(); + (cmd as any).logger = { log: jest.fn() }; + (cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } }; + (cmd as any).apolloClient = { + query: queryImpl || jest.fn(), + mutate: mutateImpl || jest.fn(), + }; + return cmd; +}; + +describe('Rollback Command', () => { + let exitMock: jest.SpyInstance; + + beforeEach(() => { + (Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() })); + exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('exits when no rollback-eligible deployments are available', async () => { + const noEligibleResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { edges: [{ node: liveDeployment }] }, + }, + }, + ], + }, + }, + }; + const query = jest.fn().mockResolvedValueOnce(noEligibleResponse); + const mutate = jest.fn(); + const cmd = buildCommand({ environment: 'Default' }, query, mutate); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'No rollback-eligible deployments are available for this environment.', + 'error', + ); + }); + + it('exits when --deployment flag does not match an eligible deployment', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'unknown-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Provided deployment UID is not rollback-eligible or does not exist.', + 'error', + ); + }); + + it('skips the mutation when the user does not confirm', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'audit' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt + + await (cmd as any).rollbackDeployment(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('fires the rollback mutation and prints the success message', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn().mockResolvedValueOnce({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'restoring' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + await (cmd as any).rollbackDeployment(); + + expect(mutate).toHaveBeenCalledTimes(1); + const variables = mutate.mock.calls[0][0].variables; + expect(variables).toEqual({ + input: { + deployment: 'target-uid', + environment: 'env-uid', + reason: 'restoring', + }, + }); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('logs an error and exits when the rollback mutation fails', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const error = Object.assign(new Error('boom'), { + graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }], + }); + const mutate = jest.fn().mockRejectedValueOnce(error); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock) + .mockResolvedValueOnce('') // reason + .mockResolvedValueOnce(true); // confirm + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Rollback failed. Please try again. (DeploymentRollbackFailed)', + 'error', + ); + }); +}); diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts new file mode 100644 index 00000000..51b9b37 --- /dev/null +++ b/src/commands/launch/rollback.ts @@ -0,0 +1,351 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import filter from 'lodash/filter'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { + environmentsQuery, + latestLiveDeploymentQuery, + rollbackDeploymentMutation, +} from '../../graphql'; +import { Logger, selectOrg, selectProject } from '../../util'; + +export default class Rollback extends BaseCommand { + static description = 'Roll back to previous deployment'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + // eslint-disable-next-line max-len + '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment= --org= --project= --reason="restoring previous build"', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + environment: Flags.string({ + char: 'e', + description: 'Environment name or UID', + }), + deployment: Flags.string({ + description: '[Optional] Deployment UID to roll back to', + }), + reason: Flags.string({ + description: '[Optional] Reason for the rollback (saved to audit log)', + }), + }; + + async init(): Promise { + await super.init(); + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + await this.prepareApiClients(); + } + + async run(): Promise { + if (!this.flags.environment) { + await this.getConfig(); + } + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.rollbackDeployment(); + } + + /** + * @method rollbackDeployment - resolve env, run select + review steps, fire mutation + * + * @memberof Rollback + */ + async rollbackDeployment(): Promise { + const environment = await this.resolveEnvironment(); + const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); + const eligibleSorted = this.getEligibleSortedDeployments(environment, currentLive?.uid); + + if (isEmpty(eligibleSorted)) { + this.log('No rollback-eligible deployments are available for this environment.', 'error'); + process.exit(1); + } + + this.printSelectStep(environment, currentLive, eligibleSorted); + const target = await this.selectDeployment(eligibleSorted); + + this.printReviewStep(currentLive, target, eligibleSorted); + const reason = await this.promptReason(); + const confirmed = await ux.inquire({ + type: 'confirm', + name: 'confirm', + message: 'Confirm & Rollback?', + }); + + if (!confirmed) { + ux.print(chalk.yellow('Rollback aborted.')); + return; + } + + try { + await this.apolloClient.mutate({ + mutation: rollbackDeploymentMutation, + variables: { + input: { + deployment: target.uid, + environment: environment.uid, + ...(reason ? { reason } : {}), + }, + }, + }); + } catch (error: unknown) { + const err = error as { graphQLErrors?: { extensions?: { exception?: { name?: string } } }[]; message?: string }; + const code = err?.graphQLErrors?.[0]?.extensions?.exception?.name || err?.message; + this.log(`Rollback failed. Please try again. (${code})`, 'error'); + process.exit(1); + } + + ux.print(''); + ux.print( + `Promoting deployment ${chalk.cyan(`#${target.deploymentNumber}`)} ` + + chalk.dim(`(${target.uid})`) + '…', + ); + + ux.print(''); + ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); + const label = `${chalk.cyan(`#${target.deploymentNumber}`)} ${chalk.dim(`(${target.uid})`)}`; + ux.print(` Deployment ${label} is now ${chalk.green('LIVE')}.`); + ux.print(''); + } + + /** + * @method resolveEnvironment - resolve environment via flag, config, or prompt + * + * @memberof Rollback + */ + async resolveEnvironment(): Promise { + const environments = await this.apolloClient + .query({ + query: environmentsQuery, + variables: { skipRollbackData: false }, + }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + + if (this.flags.environment) { + const environment = find( + environments, + ({ uid, name }) => uid === this.flags.environment || name === this.flags.environment, + ); + if (isEmpty(environment)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } + return environment; + } + + // NOTE: rollback is destructive; never auto-select from saved config — always prompt. + return ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); + } + + /** + * @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment + * + * @memberof Rollback + */ + async fetchCurrentLiveDeployment(environmentUid: string): Promise { + return this.apolloClient + .query({ + query: latestLiveDeploymentQuery, + variables: { query: { environment: environmentUid } }, + }) + .then(({ data }) => data?.latestLiveDeployment) + .catch(() => undefined); + } + + /** + * @method getEligibleSortedDeployments - eligible deployments excluding current live, sorted by number desc + * + * @memberof Rollback + */ + getEligibleSortedDeployments(environment: any, currentLiveUid?: string): any[] { + const deployments = map(environment?.deployments?.edges, 'node'); + const eligible = filter( + deployments, + (d) => d.isRollbackEligible && d.uid !== currentLiveUid, + ); + return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0)); + } + + /** + * @method selectDeployment - resolve target via --deployment flag or interactive picker + * + * @memberof Rollback + */ + async selectDeployment(eligibleSorted: any[]): Promise { + if (this.flags.deployment) { + const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment); + if (isEmpty(match)) { + this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error'); + process.exit(1); + } + return match; + } + + const choices = map(eligibleSorted, (d) => { + const message = (d.commitMessage || '').split('\n')[0].trim() || '—'; + const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message; + return { + ...d, + name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`, + value: d.uid, + }; + }); + + const selectedUid = await ux.inquire({ + type: 'search-list', + name: 'Deployment', + choices, + message: 'Select a version to restore', + }); + + return find(eligibleSorted, { uid: selectedUid }) as Record; + } + + /** + * @method promptReason - prompt for rollback reason unless provided via --reason flag + * + * @memberof Rollback + */ + async promptReason(): Promise { + if (this.flags.reason) { + return this.flags.reason.trim() || undefined; + } + const input = await ux.inquire({ + type: 'input', + name: 'reason', + message: 'Reason (saved to audit log) — press enter to skip:', + }); + const trimmed = (input || '').trim(); + return trimmed ? trimmed : undefined; + } + + /** + * @method printSelectStep - mirror the UI "select" step heading and table + * + * @memberof Rollback + */ + printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Roll back to previous deployment')); + ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`); + ux.print(''); + ux.print(chalk.bold('Currently live')); + ux.print(` ${formatDeployment(currentLive)}`); + ux.print(''); + ux.print(chalk.bold('Select a version to restore')); + ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.')); + const count = eligibleSorted.length; + ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`)); + ux.print(''); + } + + /** + * @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary + * + * @memberof Rollback + */ + printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Review rollback')); + ux.print(''); + ux.print('You are about to replace your live site with the version below.'); + ux.print('This build will be pushed to the edge immediately.'); + ux.print(''); + ux.print( + `${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`, + ); + ux.print(' associated with the selected deployment.'); + + const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid); + const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : []; + if (skipped.length > 0) { + const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', '); + const noun = skipped.length === 1 ? 'good deployment' : 'good deployments'; + const verb = skipped.length === 1 ? 'stays' : 'stay'; + ux.print(''); + ux.print( + `${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun} — ${list}`, + ); + ux.print(` ${verb} in history and can be restored later.`); + } + + ux.print(''); + ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`); + ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`); + ux.print(''); + ux.print( + chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'), + ); + ux.print(''); + } +} + +function shortHash(hash?: string): string { + return hash ? hash.substring(0, 7) : ''; +} + +function sourceLabel(deployment?: any): string { + if (!deployment) { + return ''; + } + const hash = shortHash(deployment.commitHash); + if (deployment.gitBranch && hash) { + return `${deployment.gitBranch} - ${hash}`; + } + return deployment.gitBranch || hash || ''; +} + +function formatDeployment(deployment?: any): string { + if (!deployment) { + return chalk.dim('(none)'); + } + const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; + const source = sourceLabel(deployment); + const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim(); + const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message; + const createdAt = deployment.createdAt || ''; + const numberCol = chalk.green(number.padEnd(6)); + const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22); + const messageCol = truncated || chalk.dim('—'); + return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`; +} diff --git a/src/graphql/mutation.ts b/src/graphql/mutation.ts index 97bc3f6..431ef2f 100755 --- a/src/graphql/mutation.ts +++ b/src/graphql/mutation.ts @@ -76,8 +76,18 @@ const importProjectMutation: DocumentNode = gql` } `; +const rollbackDeploymentMutation: DocumentNode = gql` + mutation RollbackDeployment($input: RollbackDeploymentInput!) { + rollbackDeployment(input: $input) { + status + environmentUid + } + } +`; + export { importProjectMutation, createDeploymentMutation, + rollbackDeploymentMutation, createSignedUploadUrlMutation, }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index c27debe..1cb0e81 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -145,12 +145,17 @@ const latestLiveDeploymentQuery: DocumentNode = gql` environment deploymentNumber deploymentUrl + status + gitBranch + commitHash + commitMessage + createdAt } } `; const environmentsQuery: DocumentNode = gql` - query Environments { + query Environments($skipRollbackData: Boolean = true) { Environments { edges { node { @@ -165,6 +170,10 @@ const environmentsQuery: DocumentNode = gql` commitMessage deploymentUrl deploymentNumber + status @skip(if: $skipRollbackData) + gitBranch @skip(if: $skipRollbackData) + commitHash @skip(if: $skipRollbackData) + isRollbackEligible @skip(if: $skipRollbackData) } } } From f57057d74a03e57d05fa7b12b9b0b9274fde3191 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Fri, 15 May 2026 12:56:06 +0530 Subject: [PATCH 04/10] test: add tests for Rollback command --- .talismanrc | 2 + test/unit/commands/rollback.test.ts | 209 ++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 test/unit/commands/rollback.test.ts diff --git a/.talismanrc b/.talismanrc index 70638de..f0cd810 100644 --- a/.talismanrc +++ b/.talismanrc @@ -8,4 +8,6 @@ fileignoreconfig: checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 - filename: src/commands/launch/rollback.test.ts checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd +- filename: test/unit/commands/rollback.test.ts + checksum: d1f931f2d9a397131409399ad6463653e28b5a2224e870b641d9ba57c4418f18 version: "1.0" \ No newline at end of file diff --git a/test/unit/commands/rollback.test.ts b/test/unit/commands/rollback.test.ts new file mode 100644 index 00000000..ed03300 --- /dev/null +++ b/test/unit/commands/rollback.test.ts @@ -0,0 +1,209 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import type { ApolloClient } from '@apollo/client/core'; +import Rollback from '../../../src/commands/launch/rollback'; +import { cliux } from '@contentstack/cli-utilities'; +import { testFlags } from '../mock'; +import sinon, { stub } from 'sinon'; +import { config } from 'dotenv'; +import * as commonUtility from '../../../src/util/common-utility'; +import { BaseCommand } from '../../../src/base-command'; + +config(); + +const orgUid = process.env.ORG || 'test-org-uid'; +const projectUid = process.env.PROJECT || 'test-project-uid'; +const environmentName = process.env.ENVIRONMENT || 'Default'; +const targetDeploymentUid = process.env.ROLLBACK_DEPLOYMENT || 'target-deployment-uid'; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: environmentName, + deployments: { + edges: [ + { + node: { + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, + isRollbackEligible: true, + }, + }, + { + node: { + uid: targetDeploymentUid, + status: 'ARCHIVED', + deploymentNumber: 2, + isRollbackEligible: true, + }, + }, + ], + }, + }, + }, + ], + }, + }, +}; + +const projectsResponse = { + data: { + projects: { + edges: [{ node: { uid: projectUid, name: 'Test Project' } }], + }, + }, +}; + +const getFlagValue = (flag: unknown): string | undefined => { + if (flag === undefined || flag === null) { + return undefined; + } + return String(flag); +}; + +const createApolloClientStub = () => ({ + query: stub().callsFake(({ query }) => { + const queryBody = query?.loc?.source?.body ?? ''; + if (queryBody.includes('projects')) { + return Promise.resolve(projectsResponse); + } + if (queryBody.includes('Environments')) { + return Promise.resolve(environmentsResponse); + } + return Promise.resolve({ data: {} }); + }), + mutate: stub().resolves({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }), +}); + +describe('Rollback', () => { + let rollbackDeploymentStub: sinon.SinonStub; + + beforeEach(() => { + stub(commonUtility, 'selectOrg').callsFake(async ({ config, flags }) => { + const orgFlag = getFlagValue(flags.org); + if (orgFlag) { + config.currentConfig.organizationUid = + orgFlag === testFlags.invalidOrg.uid ? testFlags.invalidOrg.uid : orgUid; + return; + } + config.currentConfig.organizationUid = orgUid; + }); + stub(commonUtility, 'selectProject').callsFake(async ({ config, flags }) => { + const projectFlag = getFlagValue(flags?.project) ?? config?.project; + if (projectFlag && projectFlag !== testFlags.invalidProj) { + config.currentConfig.uid = projectUid; + return; + } + if (!config.currentConfig.uid) { + await cliux.inquire({ + type: 'search-list', + name: 'Project', + message: 'Choose a project', + }); + config.currentConfig.uid = projectUid; + } + }); + stub(BaseCommand.prototype, 'prepareApiClients').callsFake(async function (this: BaseCommand) { + this.apolloClient = createApolloClientStub() as unknown as ApolloClient; + this.apolloLogsClient = {} as unknown as ApolloClient; + }); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('Should run the command when all the flags are passed', async function () { + const args = [ + '--org', + orgUid, + '-e', + environmentName, + '--project', + projectUid, + '--deployment', + targetDeploymentUid, + ]; + const inquireStub = stub(cliux, 'inquire'); + + await Rollback.run(args); + + sinon.assert.calledOnce(rollbackDeploymentStub); + sinon.assert.notCalled(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for org when org flag is not passed', async function () { + const args = ['-e', environmentName, '--project', projectUid]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when project flag is not passed', async function () { + const args = ['-e', environmentName, '--org', orgUid]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); + + it('Should ask for environment when environment flag is not passed', async function () { + rollbackDeploymentStub.restore(); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').callsFake(async function (this: Rollback) { + await this.resolveEnvironment(); + }); + + const args = ['--org', orgUid, '--project', projectUid]; + const inquireStub = stub(cliux, 'inquire').resolves(environmentName); + + await Rollback.run(args); + + sinon.assert.called(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for organization with a warning when passed incorrect org uid', async function () { + const args = ['--org', testFlags.invalidOrg.uid, '--project', projectUid, '-e', environmentName]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when passed incorrect project name', async function () { + const args = ['--org', orgUid, '--project', testFlags.invalidProj, '-e', environmentName]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); +}); From 2d1b0192804012729b760f1c6ae898ed1fa9029f Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Mon, 18 May 2026 15:53:46 +0530 Subject: [PATCH 05/10] Revert "CL-1753 | Add unit tests for Rollback command in Launch CLI" This reverts commit 34d776121dbc9e7c97e0592f062eff497be6dd55. --- .talismanrc | 2 - test/unit/commands/rollback.test.ts | 209 ---------------------------- 2 files changed, 211 deletions(-) delete mode 100644 test/unit/commands/rollback.test.ts diff --git a/.talismanrc b/.talismanrc index f0cd810..70638de 100644 --- a/.talismanrc +++ b/.talismanrc @@ -8,6 +8,4 @@ fileignoreconfig: checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 - filename: src/commands/launch/rollback.test.ts checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd -- filename: test/unit/commands/rollback.test.ts - checksum: d1f931f2d9a397131409399ad6463653e28b5a2224e870b641d9ba57c4418f18 version: "1.0" \ No newline at end of file diff --git a/test/unit/commands/rollback.test.ts b/test/unit/commands/rollback.test.ts deleted file mode 100644 index ed03300..00000000 --- a/test/unit/commands/rollback.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'mocha'; -import type { ApolloClient } from '@apollo/client/core'; -import Rollback from '../../../src/commands/launch/rollback'; -import { cliux } from '@contentstack/cli-utilities'; -import { testFlags } from '../mock'; -import sinon, { stub } from 'sinon'; -import { config } from 'dotenv'; -import * as commonUtility from '../../../src/util/common-utility'; -import { BaseCommand } from '../../../src/base-command'; - -config(); - -const orgUid = process.env.ORG || 'test-org-uid'; -const projectUid = process.env.PROJECT || 'test-project-uid'; -const environmentName = process.env.ENVIRONMENT || 'Default'; -const targetDeploymentUid = process.env.ROLLBACK_DEPLOYMENT || 'target-deployment-uid'; - -const environmentsResponse = { - data: { - Environments: { - edges: [ - { - node: { - uid: 'env-uid', - name: environmentName, - deployments: { - edges: [ - { - node: { - uid: 'live-uid', - status: 'LIVE', - deploymentNumber: 3, - isRollbackEligible: true, - }, - }, - { - node: { - uid: targetDeploymentUid, - status: 'ARCHIVED', - deploymentNumber: 2, - isRollbackEligible: true, - }, - }, - ], - }, - }, - }, - ], - }, - }, -}; - -const projectsResponse = { - data: { - projects: { - edges: [{ node: { uid: projectUid, name: 'Test Project' } }], - }, - }, -}; - -const getFlagValue = (flag: unknown): string | undefined => { - if (flag === undefined || flag === null) { - return undefined; - } - return String(flag); -}; - -const createApolloClientStub = () => ({ - query: stub().callsFake(({ query }) => { - const queryBody = query?.loc?.source?.body ?? ''; - if (queryBody.includes('projects')) { - return Promise.resolve(projectsResponse); - } - if (queryBody.includes('Environments')) { - return Promise.resolve(environmentsResponse); - } - return Promise.resolve({ data: {} }); - }), - mutate: stub().resolves({ - data: { - rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, - }, - }), -}); - -describe('Rollback', () => { - let rollbackDeploymentStub: sinon.SinonStub; - - beforeEach(() => { - stub(commonUtility, 'selectOrg').callsFake(async ({ config, flags }) => { - const orgFlag = getFlagValue(flags.org); - if (orgFlag) { - config.currentConfig.organizationUid = - orgFlag === testFlags.invalidOrg.uid ? testFlags.invalidOrg.uid : orgUid; - return; - } - config.currentConfig.organizationUid = orgUid; - }); - stub(commonUtility, 'selectProject').callsFake(async ({ config, flags }) => { - const projectFlag = getFlagValue(flags?.project) ?? config?.project; - if (projectFlag && projectFlag !== testFlags.invalidProj) { - config.currentConfig.uid = projectUid; - return; - } - if (!config.currentConfig.uid) { - await cliux.inquire({ - type: 'search-list', - name: 'Project', - message: 'Choose a project', - }); - config.currentConfig.uid = projectUid; - } - }); - stub(BaseCommand.prototype, 'prepareApiClients').callsFake(async function (this: BaseCommand) { - this.apolloClient = createApolloClientStub() as unknown as ApolloClient; - this.apolloLogsClient = {} as unknown as ApolloClient; - }); - rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').resolves(); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('Should run the command when all the flags are passed', async function () { - const args = [ - '--org', - orgUid, - '-e', - environmentName, - '--project', - projectUid, - '--deployment', - targetDeploymentUid, - ]; - const inquireStub = stub(cliux, 'inquire'); - - await Rollback.run(args); - - sinon.assert.calledOnce(rollbackDeploymentStub); - sinon.assert.notCalled(inquireStub); - inquireStub.restore(); - }); - - it('Should ask for org when org flag is not passed', async function () { - const args = ['-e', environmentName, '--project', projectUid]; - const mock = sinon.mock(Rollback); - const expectation = mock.expects('run'); - expectation.exactly(1); - const orgStub = stub(cliux, 'inquire').resolves(orgUid); - - await Rollback.run(args); - - sinon.assert.notCalled(orgStub); - orgStub.restore(); - mock.verify(); - mock.restore(); - }); - - it('Should ask for project when project flag is not passed', async function () { - const args = ['-e', environmentName, '--org', orgUid]; - const projectStub = stub(cliux, 'inquire').resolves('Test Project'); - - await Rollback.run(args); - - sinon.assert.calledOnce(projectStub); - projectStub.restore(); - }); - - it('Should ask for environment when environment flag is not passed', async function () { - rollbackDeploymentStub.restore(); - rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').callsFake(async function (this: Rollback) { - await this.resolveEnvironment(); - }); - - const args = ['--org', orgUid, '--project', projectUid]; - const inquireStub = stub(cliux, 'inquire').resolves(environmentName); - - await Rollback.run(args); - - sinon.assert.called(inquireStub); - inquireStub.restore(); - }); - - it('Should ask for organization with a warning when passed incorrect org uid', async function () { - const args = ['--org', testFlags.invalidOrg.uid, '--project', projectUid, '-e', environmentName]; - const mock = sinon.mock(Rollback); - const expectation = mock.expects('run'); - expectation.exactly(1); - const orgStub = stub(cliux, 'inquire').resolves(orgUid); - - await Rollback.run(args); - - sinon.assert.notCalled(orgStub); - orgStub.restore(); - mock.verify(); - mock.restore(); - }); - - it('Should ask for project when passed incorrect project name', async function () { - const args = ['--org', orgUid, '--project', testFlags.invalidProj, '-e', environmentName]; - const projectStub = stub(cliux, 'inquire').resolves('Test Project'); - - await Rollback.run(args); - - sinon.assert.calledOnce(projectStub); - projectStub.restore(); - }); -}); From 2bb887a3eed3987ff6fabb617149accbf9cbf994 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Mon, 18 May 2026 15:53:46 +0530 Subject: [PATCH 06/10] Revert "CL-1753 | Add instant rollback feature support for Launch CLI" This reverts commit b439d63fc80af0f1f9a96355bf11f8ba6ff4da04. --- .talismanrc | 2 - src/commands/launch/rollback.test.ts | 233 ------------------ src/commands/launch/rollback.ts | 351 --------------------------- src/graphql/mutation.ts | 10 - src/graphql/queries.ts | 11 +- 5 files changed, 1 insertion(+), 606 deletions(-) delete mode 100644 src/commands/launch/rollback.test.ts delete mode 100644 src/commands/launch/rollback.ts diff --git a/.talismanrc b/.talismanrc index 70638de..fb07040 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,6 +6,4 @@ fileignoreconfig: checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62 - filename: package-lock.json checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 -- filename: src/commands/launch/rollback.test.ts - checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd version: "1.0" \ No newline at end of file diff --git a/src/commands/launch/rollback.test.ts b/src/commands/launch/rollback.test.ts deleted file mode 100644 index 9508fec..00000000 --- a/src/commands/launch/rollback.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import Rollback from './rollback'; -import { Logger } from '../../util'; -import { cliux } from '@contentstack/cli-utilities'; - -jest.mock('../../util', () => { - const actual = jest.requireActual('../../util'); - return { - ...actual, - Logger: jest.fn(), - selectOrg: jest.fn(), - selectProject: jest.fn(), - }; -}); - -jest.mock('@contentstack/cli-utilities', () => { - const actual = jest.requireActual('@contentstack/cli-utilities'); - return { - ...actual, - configHandler: { - get: jest.fn((key) => { - if (key === 'authtoken') return 'dummy-token'; - if (key === 'authorisationType') return 'OAuth'; - if (key === 'oauthAccessToken') return 'dummy-oauth-token'; - return undefined; - }), - }, - cliux: { - ...actual.cliux, - inquire: jest.fn(), - print: jest.fn(), - }, - }; -}); - -const targetDeployment = { - uid: 'target-uid', - status: 'ARCHIVED', - gitBranch: 'main', - commitHash: 'abcdef1', - createdAt: '2026-04-29T00:00:00Z', - commitMessage: 'previous good build', - deploymentUrl: 'https://example.com', - deploymentNumber: 2, - isRollbackEligible: true, -}; - -const liveDeployment = { - ...targetDeployment, - uid: 'live-uid', - status: 'LIVE', - deploymentNumber: 3, -}; - -const environmentsResponse = { - data: { - Environments: { - edges: [ - { - node: { - uid: 'env-uid', - name: 'Default', - deployments: { - edges: [ - { node: liveDeployment }, - { node: targetDeployment }, - ], - }, - }, - }, - ], - }, - }, -}; - -const buildCommand = (flags: Record = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => { - const cmd = new Rollback([], {} as any); - (cmd as any).flags = flags; - (cmd as any).log = jest.fn(); - (cmd as any).logger = { log: jest.fn() }; - (cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } }; - (cmd as any).apolloClient = { - query: queryImpl || jest.fn(), - mutate: mutateImpl || jest.fn(), - }; - return cmd; -}; - -describe('Rollback Command', () => { - let exitMock: jest.SpyInstance; - - beforeEach(() => { - (Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() })); - exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`process.exit:${code}`); - }) as any); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('exits when no rollback-eligible deployments are available', async () => { - const noEligibleResponse = { - data: { - Environments: { - edges: [ - { - node: { - uid: 'env-uid', - name: 'Default', - deployments: { edges: [{ node: liveDeployment }] }, - }, - }, - ], - }, - }, - }; - const query = jest.fn().mockResolvedValueOnce(noEligibleResponse); - const mutate = jest.fn(); - const cmd = buildCommand({ environment: 'Default' }, query, mutate); - jest - .spyOn(cmd as any, 'fetchCurrentLiveDeployment') - .mockResolvedValueOnce(liveDeployment); - - await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); - - expect(mutate).not.toHaveBeenCalled(); - expect(exitMock).toHaveBeenCalledWith(1); - expect((cmd as any).log).toHaveBeenCalledWith( - 'No rollback-eligible deployments are available for this environment.', - 'error', - ); - }); - - it('exits when --deployment flag does not match an eligible deployment', async () => { - const query = jest.fn().mockResolvedValueOnce(environmentsResponse); - const mutate = jest.fn(); - const cmd = buildCommand( - { environment: 'Default', deployment: 'unknown-uid' }, - query, - mutate, - ); - jest - .spyOn(cmd as any, 'fetchCurrentLiveDeployment') - .mockResolvedValueOnce(liveDeployment); - - await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); - - expect(mutate).not.toHaveBeenCalled(); - expect(exitMock).toHaveBeenCalledWith(1); - expect((cmd as any).log).toHaveBeenCalledWith( - 'Provided deployment UID is not rollback-eligible or does not exist.', - 'error', - ); - }); - - it('skips the mutation when the user does not confirm', async () => { - const query = jest.fn().mockResolvedValueOnce(environmentsResponse); - const mutate = jest.fn(); - const cmd = buildCommand( - { environment: 'Default', deployment: 'target-uid', reason: 'audit' }, - query, - mutate, - ); - jest - .spyOn(cmd as any, 'fetchCurrentLiveDeployment') - .mockResolvedValueOnce(liveDeployment); - (cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt - - await (cmd as any).rollbackDeployment(); - - expect(mutate).not.toHaveBeenCalled(); - }); - - it('fires the rollback mutation and prints the success message', async () => { - const query = jest.fn().mockResolvedValueOnce(environmentsResponse); - const mutate = jest.fn().mockResolvedValueOnce({ - data: { - rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, - }, - }); - const cmd = buildCommand( - { environment: 'Default', deployment: 'target-uid', reason: 'restoring' }, - query, - mutate, - ); - jest - .spyOn(cmd as any, 'fetchCurrentLiveDeployment') - .mockResolvedValueOnce(liveDeployment); - (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); - - await (cmd as any).rollbackDeployment(); - - expect(mutate).toHaveBeenCalledTimes(1); - const variables = mutate.mock.calls[0][0].variables; - expect(variables).toEqual({ - input: { - deployment: 'target-uid', - environment: 'env-uid', - reason: 'restoring', - }, - }); - expect(exitMock).not.toHaveBeenCalled(); - }); - - it('logs an error and exits when the rollback mutation fails', async () => { - const query = jest.fn().mockResolvedValueOnce(environmentsResponse); - const error = Object.assign(new Error('boom'), { - graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }], - }); - const mutate = jest.fn().mockRejectedValueOnce(error); - const cmd = buildCommand( - { environment: 'Default', deployment: 'target-uid' }, - query, - mutate, - ); - jest - .spyOn(cmd as any, 'fetchCurrentLiveDeployment') - .mockResolvedValueOnce(liveDeployment); - (cliux.inquire as jest.Mock) - .mockResolvedValueOnce('') // reason - .mockResolvedValueOnce(true); // confirm - - await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); - - expect(mutate).toHaveBeenCalledTimes(1); - expect(exitMock).toHaveBeenCalledWith(1); - expect((cmd as any).log).toHaveBeenCalledWith( - 'Rollback failed. Please try again. (DeploymentRollbackFailed)', - 'error', - ); - }); -}); diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts deleted file mode 100644 index 51b9b37..00000000 --- a/src/commands/launch/rollback.ts +++ /dev/null @@ -1,351 +0,0 @@ -import chalk from 'chalk'; -import map from 'lodash/map'; -import find from 'lodash/find'; -import filter from 'lodash/filter'; -import isEmpty from 'lodash/isEmpty'; -import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; - -import { BaseCommand } from '../../base-command'; -import { - environmentsQuery, - latestLiveDeploymentQuery, - rollbackDeploymentMutation, -} from '../../graphql'; -import { Logger, selectOrg, selectProject } from '../../util'; - -export default class Rollback extends BaseCommand { - static description = 'Roll back to previous deployment'; - - static examples = [ - '$ <%= config.bin %> <%= command.id %>', - '$ <%= config.bin %> <%= command.id %> -d "current working directory"', - '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', - // eslint-disable-next-line max-len - '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment= --org= --project= --reason="restoring previous build"', - ]; - - static flags: FlagInput = { - org: Flags.string({ - description: '[Optional] Provide the organization UID', - }), - project: Flags.string({ - description: '[Optional] Provide the project UID', - }), - environment: Flags.string({ - char: 'e', - description: 'Environment name or UID', - }), - deployment: Flags.string({ - description: '[Optional] Deployment UID to roll back to', - }), - reason: Flags.string({ - description: '[Optional] Reason for the rollback (saved to audit log)', - }), - }; - - async init(): Promise { - await super.init(); - this.logger = new Logger(this.sharedConfig); - this.log = this.logger.log.bind(this.logger); - await this.prepareApiClients(); - } - - async run(): Promise { - if (!this.flags.environment) { - await this.getConfig(); - } - - if (!this.sharedConfig.currentConfig?.uid) { - await selectOrg({ - log: this.log, - flags: this.flags, - config: this.sharedConfig, - managementSdk: this.managementSdk, - }); - await this.prepareApiClients(); // NOTE update org-id in header - await selectProject({ - log: this.log, - flags: this.flags, - config: this.sharedConfig, - apolloClient: this.apolloClient, - }); - await this.prepareApiClients(); // NOTE update project-id in header - } - - await this.rollbackDeployment(); - } - - /** - * @method rollbackDeployment - resolve env, run select + review steps, fire mutation - * - * @memberof Rollback - */ - async rollbackDeployment(): Promise { - const environment = await this.resolveEnvironment(); - const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); - const eligibleSorted = this.getEligibleSortedDeployments(environment, currentLive?.uid); - - if (isEmpty(eligibleSorted)) { - this.log('No rollback-eligible deployments are available for this environment.', 'error'); - process.exit(1); - } - - this.printSelectStep(environment, currentLive, eligibleSorted); - const target = await this.selectDeployment(eligibleSorted); - - this.printReviewStep(currentLive, target, eligibleSorted); - const reason = await this.promptReason(); - const confirmed = await ux.inquire({ - type: 'confirm', - name: 'confirm', - message: 'Confirm & Rollback?', - }); - - if (!confirmed) { - ux.print(chalk.yellow('Rollback aborted.')); - return; - } - - try { - await this.apolloClient.mutate({ - mutation: rollbackDeploymentMutation, - variables: { - input: { - deployment: target.uid, - environment: environment.uid, - ...(reason ? { reason } : {}), - }, - }, - }); - } catch (error: unknown) { - const err = error as { graphQLErrors?: { extensions?: { exception?: { name?: string } } }[]; message?: string }; - const code = err?.graphQLErrors?.[0]?.extensions?.exception?.name || err?.message; - this.log(`Rollback failed. Please try again. (${code})`, 'error'); - process.exit(1); - } - - ux.print(''); - ux.print( - `Promoting deployment ${chalk.cyan(`#${target.deploymentNumber}`)} ` - + chalk.dim(`(${target.uid})`) + '…', - ); - - ux.print(''); - ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); - const label = `${chalk.cyan(`#${target.deploymentNumber}`)} ${chalk.dim(`(${target.uid})`)}`; - ux.print(` Deployment ${label} is now ${chalk.green('LIVE')}.`); - ux.print(''); - } - - /** - * @method resolveEnvironment - resolve environment via flag, config, or prompt - * - * @memberof Rollback - */ - async resolveEnvironment(): Promise { - const environments = await this.apolloClient - .query({ - query: environmentsQuery, - variables: { skipRollbackData: false }, - }) - .then(({ data: { Environments } }) => map(Environments.edges, 'node')) - .catch((error) => { - this.log(error?.message, 'error'); - process.exit(1); - }); - - if (this.flags.environment) { - const environment = find( - environments, - ({ uid, name }) => uid === this.flags.environment || name === this.flags.environment, - ); - if (isEmpty(environment)) { - this.log('Environment(s) not found!', 'error'); - process.exit(1); - } - return environment; - } - - // NOTE: rollback is destructive; never auto-select from saved config — always prompt. - return ux - .inquire({ - type: 'search-list', - name: 'Environment', - choices: map(environments, (row) => ({ ...row, value: row.name })), - message: 'Choose an environment', - }) - .then((name: any) => find(environments, { name }) as Record); - } - - /** - * @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment - * - * @memberof Rollback - */ - async fetchCurrentLiveDeployment(environmentUid: string): Promise { - return this.apolloClient - .query({ - query: latestLiveDeploymentQuery, - variables: { query: { environment: environmentUid } }, - }) - .then(({ data }) => data?.latestLiveDeployment) - .catch(() => undefined); - } - - /** - * @method getEligibleSortedDeployments - eligible deployments excluding current live, sorted by number desc - * - * @memberof Rollback - */ - getEligibleSortedDeployments(environment: any, currentLiveUid?: string): any[] { - const deployments = map(environment?.deployments?.edges, 'node'); - const eligible = filter( - deployments, - (d) => d.isRollbackEligible && d.uid !== currentLiveUid, - ); - return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0)); - } - - /** - * @method selectDeployment - resolve target via --deployment flag or interactive picker - * - * @memberof Rollback - */ - async selectDeployment(eligibleSorted: any[]): Promise { - if (this.flags.deployment) { - const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment); - if (isEmpty(match)) { - this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error'); - process.exit(1); - } - return match; - } - - const choices = map(eligibleSorted, (d) => { - const message = (d.commitMessage || '').split('\n')[0].trim() || '—'; - const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message; - return { - ...d, - name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`, - value: d.uid, - }; - }); - - const selectedUid = await ux.inquire({ - type: 'search-list', - name: 'Deployment', - choices, - message: 'Select a version to restore', - }); - - return find(eligibleSorted, { uid: selectedUid }) as Record; - } - - /** - * @method promptReason - prompt for rollback reason unless provided via --reason flag - * - * @memberof Rollback - */ - async promptReason(): Promise { - if (this.flags.reason) { - return this.flags.reason.trim() || undefined; - } - const input = await ux.inquire({ - type: 'input', - name: 'reason', - message: 'Reason (saved to audit log) — press enter to skip:', - }); - const trimmed = (input || '').trim(); - return trimmed ? trimmed : undefined; - } - - /** - * @method printSelectStep - mirror the UI "select" step heading and table - * - * @memberof Rollback - */ - printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void { - ux.print(''); - ux.print(chalk.bold.underline('Roll back to previous deployment')); - ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`); - ux.print(''); - ux.print(chalk.bold('Currently live')); - ux.print(` ${formatDeployment(currentLive)}`); - ux.print(''); - ux.print(chalk.bold('Select a version to restore')); - ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.')); - const count = eligibleSorted.length; - ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`)); - ux.print(''); - } - - /** - * @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary - * - * @memberof Rollback - */ - printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void { - ux.print(''); - ux.print(chalk.bold.underline('Review rollback')); - ux.print(''); - ux.print('You are about to replace your live site with the version below.'); - ux.print('This build will be pushed to the edge immediately.'); - ux.print(''); - ux.print( - `${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`, - ); - ux.print(' associated with the selected deployment.'); - - const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid); - const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : []; - if (skipped.length > 0) { - const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', '); - const noun = skipped.length === 1 ? 'good deployment' : 'good deployments'; - const verb = skipped.length === 1 ? 'stays' : 'stay'; - ux.print(''); - ux.print( - `${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun} — ${list}`, - ); - ux.print(` ${verb} in history and can be restored later.`); - } - - ux.print(''); - ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`); - ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`); - ux.print(''); - ux.print( - chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'), - ); - ux.print(''); - } -} - -function shortHash(hash?: string): string { - return hash ? hash.substring(0, 7) : ''; -} - -function sourceLabel(deployment?: any): string { - if (!deployment) { - return ''; - } - const hash = shortHash(deployment.commitHash); - if (deployment.gitBranch && hash) { - return `${deployment.gitBranch} - ${hash}`; - } - return deployment.gitBranch || hash || ''; -} - -function formatDeployment(deployment?: any): string { - if (!deployment) { - return chalk.dim('(none)'); - } - const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; - const source = sourceLabel(deployment); - const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim(); - const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message; - const createdAt = deployment.createdAt || ''; - const numberCol = chalk.green(number.padEnd(6)); - const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22); - const messageCol = truncated || chalk.dim('—'); - return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`; -} diff --git a/src/graphql/mutation.ts b/src/graphql/mutation.ts index 431ef2f..97bc3f6 100755 --- a/src/graphql/mutation.ts +++ b/src/graphql/mutation.ts @@ -76,18 +76,8 @@ const importProjectMutation: DocumentNode = gql` } `; -const rollbackDeploymentMutation: DocumentNode = gql` - mutation RollbackDeployment($input: RollbackDeploymentInput!) { - rollbackDeployment(input: $input) { - status - environmentUid - } - } -`; - export { importProjectMutation, createDeploymentMutation, - rollbackDeploymentMutation, createSignedUploadUrlMutation, }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 1cb0e81..c27debe 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -145,17 +145,12 @@ const latestLiveDeploymentQuery: DocumentNode = gql` environment deploymentNumber deploymentUrl - status - gitBranch - commitHash - commitMessage - createdAt } } `; const environmentsQuery: DocumentNode = gql` - query Environments($skipRollbackData: Boolean = true) { + query Environments { Environments { edges { node { @@ -170,10 +165,6 @@ const environmentsQuery: DocumentNode = gql` commitMessage deploymentUrl deploymentNumber - status @skip(if: $skipRollbackData) - gitBranch @skip(if: $skipRollbackData) - commitHash @skip(if: $skipRollbackData) - isRollbackEligible @skip(if: $skipRollbackData) } } } From 0e947c59bba28134228868ec7f1f5a794b52eca1 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Mon, 18 May 2026 16:31:04 +0530 Subject: [PATCH 07/10] feat: add rollback command for instantly rolling back to previous deployments --- .talismanrc | 4 + src/commands/launch/rollback.test.ts | 233 ++++++++++++++++++ src/commands/launch/rollback.ts | 351 +++++++++++++++++++++++++++ src/graphql/mutation.ts | 10 + src/graphql/queries.ts | 11 +- test/unit/commands/rollback.test.ts | 209 ++++++++++++++++ 6 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 src/commands/launch/rollback.test.ts create mode 100644 src/commands/launch/rollback.ts create mode 100644 test/unit/commands/rollback.test.ts diff --git a/.talismanrc b/.talismanrc index fb07040..19c8071 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,4 +6,8 @@ fileignoreconfig: checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62 - filename: package-lock.json checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 +- filename: src/commands/launch/rollback.test.ts + checksum: a1010882456f315a918afe2777f90472985e9966bd308c5311ac0de318b14e8c +- filename: test/unit/commands/rollback.test.ts + checksum: d1f931f2d9a397131409399ad6463653e28b5a2224e870b641d9ba57c4418f18 version: "1.0" \ No newline at end of file diff --git a/src/commands/launch/rollback.test.ts b/src/commands/launch/rollback.test.ts new file mode 100644 index 00000000..9508fec --- /dev/null +++ b/src/commands/launch/rollback.test.ts @@ -0,0 +1,233 @@ +import Rollback from './rollback'; +import { Logger } from '../../util'; +import { cliux } from '@contentstack/cli-utilities'; + +jest.mock('../../util', () => { + const actual = jest.requireActual('../../util'); + return { + ...actual, + Logger: jest.fn(), + selectOrg: jest.fn(), + selectProject: jest.fn(), + }; +}); + +jest.mock('@contentstack/cli-utilities', () => { + const actual = jest.requireActual('@contentstack/cli-utilities'); + return { + ...actual, + configHandler: { + get: jest.fn((key) => { + if (key === 'authtoken') return 'dummy-token'; + if (key === 'authorisationType') return 'OAuth'; + if (key === 'oauthAccessToken') return 'dummy-oauth-token'; + return undefined; + }), + }, + cliux: { + ...actual.cliux, + inquire: jest.fn(), + print: jest.fn(), + }, + }; +}); + +const targetDeployment = { + uid: 'target-uid', + status: 'ARCHIVED', + gitBranch: 'main', + commitHash: 'abcdef1', + createdAt: '2026-04-29T00:00:00Z', + commitMessage: 'previous good build', + deploymentUrl: 'https://example.com', + deploymentNumber: 2, + isRollbackEligible: true, +}; + +const liveDeployment = { + ...targetDeployment, + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, +}; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { + edges: [ + { node: liveDeployment }, + { node: targetDeployment }, + ], + }, + }, + }, + ], + }, + }, +}; + +const buildCommand = (flags: Record = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => { + const cmd = new Rollback([], {} as any); + (cmd as any).flags = flags; + (cmd as any).log = jest.fn(); + (cmd as any).logger = { log: jest.fn() }; + (cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } }; + (cmd as any).apolloClient = { + query: queryImpl || jest.fn(), + mutate: mutateImpl || jest.fn(), + }; + return cmd; +}; + +describe('Rollback Command', () => { + let exitMock: jest.SpyInstance; + + beforeEach(() => { + (Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() })); + exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('exits when no rollback-eligible deployments are available', async () => { + const noEligibleResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { edges: [{ node: liveDeployment }] }, + }, + }, + ], + }, + }, + }; + const query = jest.fn().mockResolvedValueOnce(noEligibleResponse); + const mutate = jest.fn(); + const cmd = buildCommand({ environment: 'Default' }, query, mutate); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'No rollback-eligible deployments are available for this environment.', + 'error', + ); + }); + + it('exits when --deployment flag does not match an eligible deployment', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'unknown-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Provided deployment UID is not rollback-eligible or does not exist.', + 'error', + ); + }); + + it('skips the mutation when the user does not confirm', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'audit' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt + + await (cmd as any).rollbackDeployment(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('fires the rollback mutation and prints the success message', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn().mockResolvedValueOnce({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'restoring' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + await (cmd as any).rollbackDeployment(); + + expect(mutate).toHaveBeenCalledTimes(1); + const variables = mutate.mock.calls[0][0].variables; + expect(variables).toEqual({ + input: { + deployment: 'target-uid', + environment: 'env-uid', + reason: 'restoring', + }, + }); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('logs an error and exits when the rollback mutation fails', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const error = Object.assign(new Error('boom'), { + graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }], + }); + const mutate = jest.fn().mockRejectedValueOnce(error); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock) + .mockResolvedValueOnce('') // reason + .mockResolvedValueOnce(true); // confirm + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Rollback failed. Please try again. (DeploymentRollbackFailed)', + 'error', + ); + }); +}); diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts new file mode 100644 index 00000000..51b9b37 --- /dev/null +++ b/src/commands/launch/rollback.ts @@ -0,0 +1,351 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import filter from 'lodash/filter'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { + environmentsQuery, + latestLiveDeploymentQuery, + rollbackDeploymentMutation, +} from '../../graphql'; +import { Logger, selectOrg, selectProject } from '../../util'; + +export default class Rollback extends BaseCommand { + static description = 'Roll back to previous deployment'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + // eslint-disable-next-line max-len + '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment= --org= --project= --reason="restoring previous build"', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + environment: Flags.string({ + char: 'e', + description: 'Environment name or UID', + }), + deployment: Flags.string({ + description: '[Optional] Deployment UID to roll back to', + }), + reason: Flags.string({ + description: '[Optional] Reason for the rollback (saved to audit log)', + }), + }; + + async init(): Promise { + await super.init(); + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + await this.prepareApiClients(); + } + + async run(): Promise { + if (!this.flags.environment) { + await this.getConfig(); + } + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.rollbackDeployment(); + } + + /** + * @method rollbackDeployment - resolve env, run select + review steps, fire mutation + * + * @memberof Rollback + */ + async rollbackDeployment(): Promise { + const environment = await this.resolveEnvironment(); + const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); + const eligibleSorted = this.getEligibleSortedDeployments(environment, currentLive?.uid); + + if (isEmpty(eligibleSorted)) { + this.log('No rollback-eligible deployments are available for this environment.', 'error'); + process.exit(1); + } + + this.printSelectStep(environment, currentLive, eligibleSorted); + const target = await this.selectDeployment(eligibleSorted); + + this.printReviewStep(currentLive, target, eligibleSorted); + const reason = await this.promptReason(); + const confirmed = await ux.inquire({ + type: 'confirm', + name: 'confirm', + message: 'Confirm & Rollback?', + }); + + if (!confirmed) { + ux.print(chalk.yellow('Rollback aborted.')); + return; + } + + try { + await this.apolloClient.mutate({ + mutation: rollbackDeploymentMutation, + variables: { + input: { + deployment: target.uid, + environment: environment.uid, + ...(reason ? { reason } : {}), + }, + }, + }); + } catch (error: unknown) { + const err = error as { graphQLErrors?: { extensions?: { exception?: { name?: string } } }[]; message?: string }; + const code = err?.graphQLErrors?.[0]?.extensions?.exception?.name || err?.message; + this.log(`Rollback failed. Please try again. (${code})`, 'error'); + process.exit(1); + } + + ux.print(''); + ux.print( + `Promoting deployment ${chalk.cyan(`#${target.deploymentNumber}`)} ` + + chalk.dim(`(${target.uid})`) + '…', + ); + + ux.print(''); + ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); + const label = `${chalk.cyan(`#${target.deploymentNumber}`)} ${chalk.dim(`(${target.uid})`)}`; + ux.print(` Deployment ${label} is now ${chalk.green('LIVE')}.`); + ux.print(''); + } + + /** + * @method resolveEnvironment - resolve environment via flag, config, or prompt + * + * @memberof Rollback + */ + async resolveEnvironment(): Promise { + const environments = await this.apolloClient + .query({ + query: environmentsQuery, + variables: { skipRollbackData: false }, + }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + + if (this.flags.environment) { + const environment = find( + environments, + ({ uid, name }) => uid === this.flags.environment || name === this.flags.environment, + ); + if (isEmpty(environment)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } + return environment; + } + + // NOTE: rollback is destructive; never auto-select from saved config — always prompt. + return ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); + } + + /** + * @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment + * + * @memberof Rollback + */ + async fetchCurrentLiveDeployment(environmentUid: string): Promise { + return this.apolloClient + .query({ + query: latestLiveDeploymentQuery, + variables: { query: { environment: environmentUid } }, + }) + .then(({ data }) => data?.latestLiveDeployment) + .catch(() => undefined); + } + + /** + * @method getEligibleSortedDeployments - eligible deployments excluding current live, sorted by number desc + * + * @memberof Rollback + */ + getEligibleSortedDeployments(environment: any, currentLiveUid?: string): any[] { + const deployments = map(environment?.deployments?.edges, 'node'); + const eligible = filter( + deployments, + (d) => d.isRollbackEligible && d.uid !== currentLiveUid, + ); + return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0)); + } + + /** + * @method selectDeployment - resolve target via --deployment flag or interactive picker + * + * @memberof Rollback + */ + async selectDeployment(eligibleSorted: any[]): Promise { + if (this.flags.deployment) { + const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment); + if (isEmpty(match)) { + this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error'); + process.exit(1); + } + return match; + } + + const choices = map(eligibleSorted, (d) => { + const message = (d.commitMessage || '').split('\n')[0].trim() || '—'; + const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message; + return { + ...d, + name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`, + value: d.uid, + }; + }); + + const selectedUid = await ux.inquire({ + type: 'search-list', + name: 'Deployment', + choices, + message: 'Select a version to restore', + }); + + return find(eligibleSorted, { uid: selectedUid }) as Record; + } + + /** + * @method promptReason - prompt for rollback reason unless provided via --reason flag + * + * @memberof Rollback + */ + async promptReason(): Promise { + if (this.flags.reason) { + return this.flags.reason.trim() || undefined; + } + const input = await ux.inquire({ + type: 'input', + name: 'reason', + message: 'Reason (saved to audit log) — press enter to skip:', + }); + const trimmed = (input || '').trim(); + return trimmed ? trimmed : undefined; + } + + /** + * @method printSelectStep - mirror the UI "select" step heading and table + * + * @memberof Rollback + */ + printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Roll back to previous deployment')); + ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`); + ux.print(''); + ux.print(chalk.bold('Currently live')); + ux.print(` ${formatDeployment(currentLive)}`); + ux.print(''); + ux.print(chalk.bold('Select a version to restore')); + ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.')); + const count = eligibleSorted.length; + ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`)); + ux.print(''); + } + + /** + * @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary + * + * @memberof Rollback + */ + printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Review rollback')); + ux.print(''); + ux.print('You are about to replace your live site with the version below.'); + ux.print('This build will be pushed to the edge immediately.'); + ux.print(''); + ux.print( + `${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`, + ); + ux.print(' associated with the selected deployment.'); + + const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid); + const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : []; + if (skipped.length > 0) { + const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', '); + const noun = skipped.length === 1 ? 'good deployment' : 'good deployments'; + const verb = skipped.length === 1 ? 'stays' : 'stay'; + ux.print(''); + ux.print( + `${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun} — ${list}`, + ); + ux.print(` ${verb} in history and can be restored later.`); + } + + ux.print(''); + ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`); + ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`); + ux.print(''); + ux.print( + chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'), + ); + ux.print(''); + } +} + +function shortHash(hash?: string): string { + return hash ? hash.substring(0, 7) : ''; +} + +function sourceLabel(deployment?: any): string { + if (!deployment) { + return ''; + } + const hash = shortHash(deployment.commitHash); + if (deployment.gitBranch && hash) { + return `${deployment.gitBranch} - ${hash}`; + } + return deployment.gitBranch || hash || ''; +} + +function formatDeployment(deployment?: any): string { + if (!deployment) { + return chalk.dim('(none)'); + } + const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; + const source = sourceLabel(deployment); + const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim(); + const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message; + const createdAt = deployment.createdAt || ''; + const numberCol = chalk.green(number.padEnd(6)); + const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22); + const messageCol = truncated || chalk.dim('—'); + return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`; +} diff --git a/src/graphql/mutation.ts b/src/graphql/mutation.ts index 97bc3f6..431ef2f 100755 --- a/src/graphql/mutation.ts +++ b/src/graphql/mutation.ts @@ -76,8 +76,18 @@ const importProjectMutation: DocumentNode = gql` } `; +const rollbackDeploymentMutation: DocumentNode = gql` + mutation RollbackDeployment($input: RollbackDeploymentInput!) { + rollbackDeployment(input: $input) { + status + environmentUid + } + } +`; + export { importProjectMutation, createDeploymentMutation, + rollbackDeploymentMutation, createSignedUploadUrlMutation, }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index c27debe..1cb0e81 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -145,12 +145,17 @@ const latestLiveDeploymentQuery: DocumentNode = gql` environment deploymentNumber deploymentUrl + status + gitBranch + commitHash + commitMessage + createdAt } } `; const environmentsQuery: DocumentNode = gql` - query Environments { + query Environments($skipRollbackData: Boolean = true) { Environments { edges { node { @@ -165,6 +170,10 @@ const environmentsQuery: DocumentNode = gql` commitMessage deploymentUrl deploymentNumber + status @skip(if: $skipRollbackData) + gitBranch @skip(if: $skipRollbackData) + commitHash @skip(if: $skipRollbackData) + isRollbackEligible @skip(if: $skipRollbackData) } } } diff --git a/test/unit/commands/rollback.test.ts b/test/unit/commands/rollback.test.ts new file mode 100644 index 00000000..ed03300 --- /dev/null +++ b/test/unit/commands/rollback.test.ts @@ -0,0 +1,209 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import type { ApolloClient } from '@apollo/client/core'; +import Rollback from '../../../src/commands/launch/rollback'; +import { cliux } from '@contentstack/cli-utilities'; +import { testFlags } from '../mock'; +import sinon, { stub } from 'sinon'; +import { config } from 'dotenv'; +import * as commonUtility from '../../../src/util/common-utility'; +import { BaseCommand } from '../../../src/base-command'; + +config(); + +const orgUid = process.env.ORG || 'test-org-uid'; +const projectUid = process.env.PROJECT || 'test-project-uid'; +const environmentName = process.env.ENVIRONMENT || 'Default'; +const targetDeploymentUid = process.env.ROLLBACK_DEPLOYMENT || 'target-deployment-uid'; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: environmentName, + deployments: { + edges: [ + { + node: { + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, + isRollbackEligible: true, + }, + }, + { + node: { + uid: targetDeploymentUid, + status: 'ARCHIVED', + deploymentNumber: 2, + isRollbackEligible: true, + }, + }, + ], + }, + }, + }, + ], + }, + }, +}; + +const projectsResponse = { + data: { + projects: { + edges: [{ node: { uid: projectUid, name: 'Test Project' } }], + }, + }, +}; + +const getFlagValue = (flag: unknown): string | undefined => { + if (flag === undefined || flag === null) { + return undefined; + } + return String(flag); +}; + +const createApolloClientStub = () => ({ + query: stub().callsFake(({ query }) => { + const queryBody = query?.loc?.source?.body ?? ''; + if (queryBody.includes('projects')) { + return Promise.resolve(projectsResponse); + } + if (queryBody.includes('Environments')) { + return Promise.resolve(environmentsResponse); + } + return Promise.resolve({ data: {} }); + }), + mutate: stub().resolves({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }), +}); + +describe('Rollback', () => { + let rollbackDeploymentStub: sinon.SinonStub; + + beforeEach(() => { + stub(commonUtility, 'selectOrg').callsFake(async ({ config, flags }) => { + const orgFlag = getFlagValue(flags.org); + if (orgFlag) { + config.currentConfig.organizationUid = + orgFlag === testFlags.invalidOrg.uid ? testFlags.invalidOrg.uid : orgUid; + return; + } + config.currentConfig.organizationUid = orgUid; + }); + stub(commonUtility, 'selectProject').callsFake(async ({ config, flags }) => { + const projectFlag = getFlagValue(flags?.project) ?? config?.project; + if (projectFlag && projectFlag !== testFlags.invalidProj) { + config.currentConfig.uid = projectUid; + return; + } + if (!config.currentConfig.uid) { + await cliux.inquire({ + type: 'search-list', + name: 'Project', + message: 'Choose a project', + }); + config.currentConfig.uid = projectUid; + } + }); + stub(BaseCommand.prototype, 'prepareApiClients').callsFake(async function (this: BaseCommand) { + this.apolloClient = createApolloClientStub() as unknown as ApolloClient; + this.apolloLogsClient = {} as unknown as ApolloClient; + }); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('Should run the command when all the flags are passed', async function () { + const args = [ + '--org', + orgUid, + '-e', + environmentName, + '--project', + projectUid, + '--deployment', + targetDeploymentUid, + ]; + const inquireStub = stub(cliux, 'inquire'); + + await Rollback.run(args); + + sinon.assert.calledOnce(rollbackDeploymentStub); + sinon.assert.notCalled(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for org when org flag is not passed', async function () { + const args = ['-e', environmentName, '--project', projectUid]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when project flag is not passed', async function () { + const args = ['-e', environmentName, '--org', orgUid]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); + + it('Should ask for environment when environment flag is not passed', async function () { + rollbackDeploymentStub.restore(); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').callsFake(async function (this: Rollback) { + await this.resolveEnvironment(); + }); + + const args = ['--org', orgUid, '--project', projectUid]; + const inquireStub = stub(cliux, 'inquire').resolves(environmentName); + + await Rollback.run(args); + + sinon.assert.called(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for organization with a warning when passed incorrect org uid', async function () { + const args = ['--org', testFlags.invalidOrg.uid, '--project', projectUid, '-e', environmentName]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when passed incorrect project name', async function () { + const args = ['--org', orgUid, '--project', testFlags.invalidProj, '-e', environmentName]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); +}); From 51c39b9cd031ee88ff0f89d77605e14b90fec95b Mon Sep 17 00:00:00 2001 From: Chhavi-Mandowara Date: Wed, 22 Apr 2026 18:45:10 +0530 Subject: [PATCH 08/10] feat: add streaming option during project creation --- src/adapters/file-upload.test.ts | 235 +++++++++++++++++++++++++++++++ src/adapters/file-upload.ts | 22 ++- src/adapters/github.test.ts | 190 +++++++++++++++++++++++++ src/adapters/github.ts | 13 ++ src/commands/launch/index.ts | 6 + src/config/index.ts | 3 +- src/types/launch.ts | 7 +- 7 files changed, 472 insertions(+), 4 deletions(-) diff --git a/src/adapters/file-upload.test.ts b/src/adapters/file-upload.test.ts index f974074..6245420 100644 --- a/src/adapters/file-upload.test.ts +++ b/src/adapters/file-upload.test.ts @@ -304,6 +304,241 @@ describe('FileUpload Adapter', () => { ); expect(serverCommandCalls.length).toBe(0); }); + + it('should prompt Enable Streaming Responses after server command when response-mode omitted for OTHER preset', + async () => { + (cliux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('./dist'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('npm start'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + const createSignedUploadUrlMock = jest + .spyOn(FileUpload.prototype as any, 'createSignedUploadUrl') + .mockResolvedValue({ uploadUid: 'test-upload-uid' }); + const archiveMock = jest + .spyOn(FileUpload.prototype as any, 'archive') + .mockResolvedValue({ zipName: 'test.zip', zipPath: '/path/to/test.zip', projectName: 'test-project' }); + const uploadFileMock = jest + .spyOn(FileUpload.prototype as any, 'uploadFile') + .mockResolvedValue(undefined); + + const fileUploadInstance = new FileUpload({ + config: { + flags: { + 'server-command': undefined, + 'response-mode': undefined, + }, + framework: 'OTHER', + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { OTHER: './dist' }, + }, + log: logMock, + exit: exitMock, + } as any); + + await fileUploadInstance.prepareAndUploadNewProjectFile(); + + expect(cliux.inquire).toHaveBeenCalledWith({ + type: 'confirm', + name: 'enableStreamingResponse', + message: 'Enable Streaming Responses', + default: false, + }); + expect(fileUploadInstance.config.isStreamingEnabled).toBe(true); + + createSignedUploadUrlMock.mockRestore(); + archiveMock.mockRestore(); + uploadFileMock.mockRestore(); + }); + + it('should not prompt Enable Streaming Response when response-mode flag is streaming', async () => { + (cliux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('./dist'); + + const createSignedUploadUrlMock = jest + .spyOn(FileUpload.prototype as any, 'createSignedUploadUrl') + .mockResolvedValue({ uploadUid: 'test-upload-uid' }); + const archiveMock = jest + .spyOn(FileUpload.prototype as any, 'archive') + .mockResolvedValue({ zipName: 'test.zip', zipPath: '/path/to/test.zip', projectName: 'test-project' }); + const uploadFileMock = jest + .spyOn(FileUpload.prototype as any, 'uploadFile') + .mockResolvedValue(undefined); + + const fileUploadInstance = new FileUpload({ + config: { + flags: { + 'server-command': 'npm start', + 'response-mode': 'streaming', + }, + framework: 'OTHER', + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { OTHER: './dist' }, + }, + log: logMock, + exit: exitMock, + } as any); + + await fileUploadInstance.prepareAndUploadNewProjectFile(); + + const enableStreamingCalls = (cliux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'enableStreamingResponse', + ); + expect(enableStreamingCalls.length).toBe(0); + expect(fileUploadInstance.config.isStreamingEnabled).toBe(true); + + createSignedUploadUrlMock.mockRestore(); + archiveMock.mockRestore(); + uploadFileMock.mockRestore(); + }); + + it('should not prompt Enable Streaming Response when response-mode flag is buffered', async () => { + (cliux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('./dist'); + + const createSignedUploadUrlMock = jest + .spyOn(FileUpload.prototype as any, 'createSignedUploadUrl') + .mockResolvedValue({ uploadUid: 'test-upload-uid' }); + const archiveMock = jest + .spyOn(FileUpload.prototype as any, 'archive') + .mockResolvedValue({ zipName: 'test.zip', zipPath: '/path/to/test.zip', projectName: 'test-project' }); + const uploadFileMock = jest + .spyOn(FileUpload.prototype as any, 'uploadFile') + .mockResolvedValue(undefined); + + const fileUploadInstance = new FileUpload({ + config: { + flags: { + 'server-command': 'npm start', + 'response-mode': 'buffered', + }, + framework: 'OTHER', + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { OTHER: './dist' }, + }, + log: logMock, + exit: exitMock, + } as any); + + await fileUploadInstance.prepareAndUploadNewProjectFile(); + + const enableStreamingCalls = (cliux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'enableStreamingResponse', + ); + expect(enableStreamingCalls.length).toBe(0); + expect(fileUploadInstance.config.isStreamingEnabled).toBe(false); + + createSignedUploadUrlMock.mockRestore(); + archiveMock.mockRestore(); + uploadFileMock.mockRestore(); + }); + + it('should prompt Enable Streaming Responses for Gatsby when flag is not provided', async () => { + (cliux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('./public'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + const createSignedUploadUrlMock = jest + .spyOn(FileUpload.prototype as any, 'createSignedUploadUrl') + .mockResolvedValue({ uploadUid: 'test-upload-uid' }); + const archiveMock = jest + .spyOn(FileUpload.prototype as any, 'archive') + .mockResolvedValue({ zipName: 'test.zip', zipPath: '/path/to/test.zip', projectName: 'test-project' }); + const uploadFileMock = jest + .spyOn(FileUpload.prototype as any, 'uploadFile') + .mockResolvedValue(undefined); + + const fileUploadInstance = new FileUpload({ + config: { + flags: { + 'response-mode': undefined, + }, + framework: 'GATSBY', + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { GATSBY: './public' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(fileUploadInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await fileUploadInstance.prepareAndUploadNewProjectFile(); + + const serverCommandCalls = (cliux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'serverCommand', + ); + expect(serverCommandCalls.length).toBe(0); + expect(cliux.inquire).toHaveBeenCalledWith({ + type: 'confirm', + name: 'enableStreamingResponse', + message: 'Enable Streaming Responses', + default: false, + }); + expect(fileUploadInstance.config.isStreamingEnabled).toBe(true); + + createSignedUploadUrlMock.mockRestore(); + archiveMock.mockRestore(); + uploadFileMock.mockRestore(); + handleEnvImportFlowMock.mockRestore(); + }); + + it('should apply response-mode flag for Gatsby without prompt', async () => { + (cliux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (cliux.inquire as jest.Mock).mockResolvedValueOnce('./public'); + + const createSignedUploadUrlMock = jest + .spyOn(FileUpload.prototype as any, 'createSignedUploadUrl') + .mockResolvedValue({ uploadUid: 'test-upload-uid' }); + const archiveMock = jest + .spyOn(FileUpload.prototype as any, 'archive') + .mockResolvedValue({ zipName: 'test.zip', zipPath: '/path/to/test.zip', projectName: 'test-project' }); + const uploadFileMock = jest + .spyOn(FileUpload.prototype as any, 'uploadFile') + .mockResolvedValue(undefined); + + const fileUploadInstance = new FileUpload({ + config: { + flags: { + 'response-mode': 'buffered', + }, + framework: 'GATSBY', + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { GATSBY: './public' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(fileUploadInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await fileUploadInstance.prepareAndUploadNewProjectFile(); + + const enableStreamingCalls = (cliux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'enableStreamingResponse', + ); + expect(enableStreamingCalls.length).toBe(0); + expect(fileUploadInstance.config.isStreamingEnabled).toBe(false); + + createSignedUploadUrlMock.mockRestore(); + archiveMock.mockRestore(); + uploadFileMock.mockRestore(); + handleEnvImportFlowMock.mockRestore(); + }); }); }); diff --git a/src/adapters/file-upload.ts b/src/adapters/file-upload.ts index e571736..3fe030b 100755 --- a/src/adapters/file-upload.ts +++ b/src/adapters/file-upload.ts @@ -114,7 +114,15 @@ export default class FileUpload extends BaseClass { * @memberof FileUpload */ async createNewProject(uploadUid: string): Promise { - const { framework, projectName, buildCommand, outputDirectory, environmentName, serverCommand } = this.config; + const { + framework, + projectName, + buildCommand, + outputDirectory, + environmentName, + serverCommand, + isStreamingEnabled + } = this.config; await this.apolloClient .mutate({ mutation: importProjectMutation, @@ -130,6 +138,7 @@ export default class FileUpload extends BaseClass { environmentVariables: map(this.envVariables, ({ key, value }) => ({ key, value })), buildCommand: buildCommand === undefined || buildCommand === null ? 'npm run build' : buildCommand, ...(serverCommand && serverCommand.trim() !== '' ? { serverCommand } : {}), + isStreamingEnabled: isStreamingEnabled ?? false, }, }, skipGitData: true, @@ -167,6 +176,7 @@ export default class FileUpload extends BaseClass { 'variable-type': variableType, 'env-variables': envVariables, 'server-command': serverCommand, + 'response-mode': responseMode, alias, } = this.config.flags; const { token, apiKey } = configHandler.get(`tokens.${alias}`) ?? {}; @@ -239,6 +249,16 @@ export default class FileUpload extends BaseClass { this.config.serverCommand = serverCommand; } } + if (!responseMode) { + this.config.isStreamingEnabled = (await cliux.inquire({ + type: 'confirm', + name: 'enableStreamingResponse', + message: 'Enable Streaming Responses', + default: false, + })) as boolean; + } else { + this.config.isStreamingEnabled = responseMode === 'streaming'; + } this.config.variableType = variableType as unknown as string; this.config.envVariables = envVariables; await this.handleEnvImportFlow(); diff --git a/src/adapters/github.test.ts b/src/adapters/github.test.ts index 1b24329..3805c3b 100644 --- a/src/adapters/github.test.ts +++ b/src/adapters/github.test.ts @@ -564,5 +564,195 @@ describe('GitHub Adapter', () => { ); expect(serverCommandCalls.length).toBe(0); }); + + it('should prompt Enable Streaming Responses after server command when response-mode omitted for OTHER preset', + async () => { + (ux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('./dist'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('npm start'); + (ux.inquire as jest.Mock).mockResolvedValueOnce(true); + + const githubInstance = new GitHub({ + config: { + flags: { + 'server-command': undefined, + 'response-mode': undefined, + }, + framework: 'OTHER', + repository: { fullName: 'test-user/repo', name: 'repo' }, + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { OTHER: './dist' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(githubInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await githubInstance.prepareForNewProjectCreation(); + + expect(ux.inquire).toHaveBeenCalledWith({ + type: 'confirm', + name: 'enableStreamingResponse', + message: 'Enable Streaming Responses', + default: false, + }); + expect(githubInstance.config.isStreamingEnabled).toBe(true); + + handleEnvImportFlowMock.mockRestore(); + }); + + it('should not prompt Enable Streaming Response when response-mode flag is streaming', async () => { + (ux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('./dist'); + + const githubInstance = new GitHub({ + config: { + flags: { + 'server-command': 'npm start', + 'response-mode': 'streaming', + }, + framework: 'OTHER', + repository: { fullName: 'test-user/repo', name: 'repo' }, + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { OTHER: './dist' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(githubInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await githubInstance.prepareForNewProjectCreation(); + + const enableStreamingCalls = (ux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'enableStreamingResponse', + ); + expect(enableStreamingCalls.length).toBe(0); + expect(githubInstance.config.isStreamingEnabled).toBe(true); + + handleEnvImportFlowMock.mockRestore(); + }); + + it('should not prompt Enable Streaming Response when response-mode flag is buffered', async () => { + (ux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('./dist'); + + const githubInstance = new GitHub({ + config: { + flags: { + 'server-command': 'npm start', + 'response-mode': 'buffered', + }, + framework: 'OTHER', + repository: { fullName: 'test-user/repo', name: 'repo' }, + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { OTHER: './dist' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(githubInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await githubInstance.prepareForNewProjectCreation(); + + const enableStreamingCalls = (ux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'enableStreamingResponse', + ); + expect(enableStreamingCalls.length).toBe(0); + expect(githubInstance.config.isStreamingEnabled).toBe(false); + + handleEnvImportFlowMock.mockRestore(); + }); + + it('should prompt Enable Streaming Responses for Gatsby when flag is not provided', async () => { + (ux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('./public'); + (ux.inquire as jest.Mock).mockResolvedValueOnce(false); + + const githubInstance = new GitHub({ + config: { + flags: { + 'response-mode': undefined, + }, + framework: 'GATSBY', + repository: { fullName: 'test-user/repo', name: 'repo' }, + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { GATSBY: './public' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(githubInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await githubInstance.prepareForNewProjectCreation(); + + const serverCommandCalls = (ux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'serverCommand', + ); + expect(serverCommandCalls.length).toBe(0); + expect(ux.inquire).toHaveBeenCalledWith({ + type: 'confirm', + name: 'enableStreamingResponse', + message: 'Enable Streaming Responses', + default: false, + }); + expect(githubInstance.config.isStreamingEnabled).toBe(false); + + handleEnvImportFlowMock.mockRestore(); + }); + + it('should apply response-mode flag for Gatsby without prompt', async () => { + (ux.inquire as jest.Mock).mockResolvedValueOnce('test-project'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('Default'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('npm run build'); + (ux.inquire as jest.Mock).mockResolvedValueOnce('./public'); + + const githubInstance = new GitHub({ + config: { + flags: { + 'response-mode': 'streaming', + }, + framework: 'GATSBY', + repository: { fullName: 'test-user/repo', name: 'repo' }, + supportedFrameworksForServerCommands: ['ANGULAR', 'OTHER', 'REMIX', 'NUXT'], + outputDirectories: { GATSBY: './public' }, + }, + log: logMock, + exit: exitMock, + } as any); + + const handleEnvImportFlowMock = jest + .spyOn(githubInstance, 'handleEnvImportFlow' as any) + .mockResolvedValue(undefined); + + await githubInstance.prepareForNewProjectCreation(); + + const enableStreamingCalls = (ux.inquire as jest.Mock).mock.calls.filter( + (call) => call[0]?.name === 'enableStreamingResponse', + ); + expect(enableStreamingCalls.length).toBe(0); + expect(githubInstance.config.isStreamingEnabled).toBe(true); + + handleEnvImportFlowMock.mockRestore(); + }); }); }); diff --git a/src/adapters/github.ts b/src/adapters/github.ts index a1032dc..6fb39a4 100755 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -100,6 +100,7 @@ export default class GitHub extends BaseClass { environmentName, provider: gitProvider, serverCommand, + isStreamingEnabled, } = this.config; const username = split(repository?.fullName, '/')[0]; @@ -124,6 +125,7 @@ export default class GitHub extends BaseClass { environmentVariables: map(this.envVariables, ({ key, value }) => ({ key, value })), buildCommand: buildCommand === undefined || buildCommand === null ? 'npm run build' : buildCommand, ...(serverCommand && serverCommand.trim() !== '' ? { serverCommand } : {}), + isStreamingEnabled: isStreamingEnabled ?? false, }, }, }, @@ -160,6 +162,7 @@ export default class GitHub extends BaseClass { 'variable-type': variableType, 'env-variables': envVariables, 'server-command': serverCommand, + 'response-mode': responseMode, alias, } = this.config.flags; const { token, apiKey } = configHandler.get(`tokens.${alias}`) ?? {}; @@ -234,6 +237,16 @@ export default class GitHub extends BaseClass { this.config.serverCommand = serverCommand; } } + if (!responseMode) { + this.config.isStreamingEnabled = (await ux.inquire({ + type: 'confirm', + name: 'enableStreamingResponse', + message: 'Enable Streaming Responses', + default: false, + })) as boolean; + } else { + this.config.isStreamingEnabled = responseMode === 'streaming'; + } this.config.variableType = variableType as unknown as string; this.config.envVariables = envVariables; await this.handleEnvImportFlow(); diff --git a/src/commands/launch/index.ts b/src/commands/launch/index.ts index 4ffc745..72f5f0e 100755 --- a/src/commands/launch/index.ts +++ b/src/commands/launch/index.ts @@ -26,6 +26,8 @@ export default class Launch extends BaseCommand { // eslint-disable-next-line max-len '<%= config.bin %> <%= command.id %> --config --type --name= --environment= --branch= --build-command= --framework=