Skip to content

Commit

Permalink
fix: Move production check logic into the runner itself
Browse files Browse the repository at this point in the history
  • Loading branch information
dipasqualew committed Mar 12, 2021
1 parent c21603b commit c21d24b
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 74 deletions.
29 changes: 1 addition & 28 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import prompts from 'prompts';
import yargs from 'yargs';

import * as utils from '../utils';
Expand All @@ -9,8 +8,6 @@ export interface DeployContext {
flags?: string;
}

export const CONFIRM_DEPLOY_TO_PROD = 'deploy-to-production';

/**
* Adds the deploy command CLI args to yargs
*/
Expand All @@ -27,42 +24,18 @@ export const addDeployArgs = (): yargs.Argv<unknown> => yargs.command('deploy <s
});
});

/**
* Asks the user to confirm
* a deploy to production
*/
export const confirmDeployToProduction = async (): Promise<string> => {
const { confirmProd } = await prompts({
type: 'text',
name: 'confirmProd',
message: `Please type '${CONFIRM_DEPLOY_TO_PROD}' to confirm deploy to production.`,
});

return confirmProd;
};

/**
* Deploys to the given stage
*
* @param context
*/
export const deploy = async (context: DeployContext): Promise<number> => {
const stage = context.stage || 'dev';

if (stage === 'production') {
const confirm = await confirmDeployToProduction();

if (confirm !== CONFIRM_DEPLOY_TO_PROD) {
throw new Error('Bailing out.');
}
}

const command = 'yarn';
const commandArgs = [
'sls',
'deploy',
'--stage',
stage,
context.stage || 'dev',
'--region',
context.region || '',
...(context.flags?.split(' ') || []),
Expand Down
75 changes: 65 additions & 10 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import path from 'path';

import consola from 'consola';
import { config } from 'dotenv';
import prompts from 'prompts';

import { deploy } from './commands/deploy';

export const CONFIRM_PRODUCTION = 'confirm-production';

export const commands = {
deploy,
};
Expand All @@ -17,8 +20,10 @@ export interface CLIArgs {
'env-path'?: string;
}

export type Executor = (context: CLIArgs) => Promise<number>;

export interface Commands {
[name: string]: (context: CLIArgs) => Promise<number>
[name: string]: Executor
}

export interface CLIRunnerOptions<T extends Commands> {
Expand Down Expand Up @@ -60,6 +65,61 @@ export const loadEnv = (context: CLIArgs): void => {
config({ path: envFilePath });
};

/**
* Prompts a user to confirm
* they are targeting production
*/
export const promptProduction = async (): Promise<string> => {
const { confirm } = await prompts({
type: 'text',
name: 'confirm',
message: `Please type '${CONFIRM_PRODUCTION}' to confirm deploy to production.`,
});

return confirm;
};

/**
* Checks whether the script
* is targeting production
*
* @param context
*/
export const checkProduction = async (context: CLIArgs): Promise<void> => {
if (
context.stage === 'production'
|| process.env.DEPLOY_ENV === 'production'
|| process.env.NODE_ENV === 'production'
) {
const confirm = await promptProduction();

if (confirm !== CONFIRM_PRODUCTION) {
throw new Error('Bailing out.');
}

consola.warn('Targetting production');
}
};

/**
* Returns the command executor
*
* @param options
* @param context
*/
export const getExecutor = <T extends Commands>(options: CLIRunnerOptions<T>, context: CLIArgs): Executor => {
const [command] = context._;
const executor = options.commands[command as keyof T];

if (!executor) {
throw new Error(`'${command}' is not a valid command.`);
}

consola.info(`Running: ${command}`);

return executor;
};

/**
* Runs as a main function
*
Expand All @@ -69,21 +129,16 @@ export const runner = async <T extends Commands>(options: CLIRunnerOptions<T>):
try {
const context = options.getArgs();

const [command] = context._;
const executor = options.commands[command as keyof T];

if (!executor) {
throw new Error(`'${command}' is not a valid command.`);
}
const executor = getExecutor(options, context);

consola.info(`Running: ${command}`);
await checkProduction(context);

// Default to using `loadEnv`
(options.loadEnv || loadEnv)(context);

await executor(context);
process.exitCode = await executor(context);
} catch (error) {
consola.error(error);
process.exit(1);
process.exitCode = 1;
}
};
4 changes: 2 additions & 2 deletions tests/unit/commands/__snapshots__/deploy.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ exports[`unit.commands.deploy deploy with stage: dev returns status code 0 1`] =

exports[`unit.commands.deploy deploy with stage: dev throws with an exit code > 0 1`] = `undefined`;

exports[`unit.commands.deploy deploy with stage: production accepts 'deploy-to-production' 1`] = `0`;
exports[`unit.commands.deploy deploy with stage: production returns status code 0 1`] = `0`;

exports[`unit.commands.deploy deploy with stage: production fails with any text 1`] = `"Bailing out."`;
exports[`unit.commands.deploy deploy with stage: production throws with an exit code > 0 1`] = `undefined`;

exports[`unit.commands.deploy deploy with stage: staging returns status code 0 1`] = `0`;

Expand Down
28 changes: 1 addition & 27 deletions tests/unit/commands/deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,24 @@ describe('unit.commands.deploy', () => {
afterEach(() => jest.resetAllMocks());

describe('deploy', () => {
describe('with stage: production', () => {
it('fails with any text', () => {
jest.spyOn(deploy, 'confirmDeployToProduction').mockImplementation(() => Promise.resolve('abc'));
jest.spyOn(utils, 'spawn').mockImplementation(() => Promise.resolve(0));

const promise = deploy.deploy({ stage: 'production' });

expect(promise).rejects.toThrowErrorMatchingSnapshot();
expect(deploy.confirmDeployToProduction).toHaveBeenCalledTimes(1);
});

it(`accepts '${deploy.CONFIRM_DEPLOY_TO_PROD}'`, () => {
jest.spyOn(deploy, 'confirmDeployToProduction').mockImplementation(() => Promise.resolve(deploy.CONFIRM_DEPLOY_TO_PROD));
jest.spyOn(utils, 'spawn').mockImplementation(() => Promise.resolve(0));

const promise = deploy.deploy({ stage: 'production' });

expect(promise).resolves.toMatchSnapshot();
expect(deploy.confirmDeployToProduction).toHaveBeenCalledTimes(1);
});
});

['dev', 'staging'].forEach((stage) => {
['dev', 'staging', 'production'].forEach((stage) => {
describe(`with stage: ${stage}`, () => {
it('throws with an exit code > 0', () => {
jest.spyOn(deploy, 'confirmDeployToProduction').mockImplementation(() => Promise.resolve(deploy.CONFIRM_DEPLOY_TO_PROD));
// eslint-disable-next-line prefer-promise-reject-errors
jest.spyOn(utils, 'spawn').mockImplementation(() => Promise.reject(1));

const promise = deploy.deploy({ stage });

expect(promise).rejects.toThrowErrorMatchingSnapshot();
expect(deploy.confirmDeployToProduction).toHaveBeenCalledTimes(0);
});

it('returns status code 0', () => {
jest.spyOn(deploy, 'confirmDeployToProduction').mockImplementation(() => Promise.resolve(deploy.CONFIRM_DEPLOY_TO_PROD));
// eslint-disable-next-line prefer-promise-reject-errors
jest.spyOn(utils, 'spawn').mockImplementation(() => Promise.resolve(0));

const promise = deploy.deploy({ stage });

expect(promise).resolves.toMatchSnapshot();
expect(deploy.confirmDeployToProduction).toHaveBeenCalledTimes(0);
});
});
});
Expand Down
72 changes: 65 additions & 7 deletions tests/unit/runner.spec.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,100 @@
import { CLIArgs, getEnvFilePath } from '../../src/runner';
import consola from 'consola';

const getContext = (overrides: Record<string, unknown> = {}): CLIArgs => ({
import * as runner from '../../src/runner';

const getContext = (overrides: Record<string, unknown> = {}): runner.CLIArgs => ({
_: ['test'],
$0: 'runner.spec.ts',
...overrides,
});

describe('unit.runner', () => {
let currentExitCode = process.exitCode;

beforeAll(() => {
currentExitCode = process.exitCode;
});

afterEach(() => {
process.exitCode = currentExitCode;
jest.resetAllMocks();
});

describe('getEnvFilePath', () => {
it('resolves the supplied --env-path=.env', () => {
const context = getContext({ 'env-path': '.env' });
const actual = getEnvFilePath(context);
const actual = runner.getEnvFilePath(context);
expect(actual).toMatch(/^.+\/.env$/);
});

it('resolves the supplied --env-path=./.env', () => {
const context = getContext({ 'env-path': './.env' });
const actual = getEnvFilePath(context);
const actual = runner.getEnvFilePath(context);
expect(actual).toMatch(/^.+\/.env$/);
});

it('resolves the supplied --env-path=path/.env', () => {
const context = getContext({ 'env-path': 'path/.env' });
const actual = getEnvFilePath(context);
const actual = runner.getEnvFilePath(context);
expect(actual).toMatch(/^.+\/path\/.env$/);
});

it('resolves the supplied --env-path=./path/.env', () => {
const context = getContext({ 'env-path': './path/.env' });
const actual = getEnvFilePath(context);
const actual = runner.getEnvFilePath(context);
expect(actual).toMatch(/^.+\/path\/.env$/);
});

it('returns the supplied --env-path=/etc/path/to/env as is', () => {
const envPath = '/etc/path/to/env';
const context = getContext({ 'env-path': envPath });
const actual = getEnvFilePath(context);
const actual = runner.getEnvFilePath(context);
expect(actual).toEqual(envPath);
});
});

describe('runner', () => {
const getOptions = (overrides: Record<string, unknown> = {}) => ({
getArgs: () => ({
_: ['test'],
$0: 'jest',
...overrides,
}),
commands: {
test: jest.fn(() => Promise.resolve(1)),
},
});

describe('targeting production', () => {
it('fails with any text', async () => {
const options = getOptions({ stage: 'production' });
jest.spyOn(consola, 'info').mockImplementation();
jest.spyOn(consola, 'warn').mockImplementation();
jest.spyOn(consola, 'error').mockImplementation();
jest.spyOn(runner, 'promptProduction').mockImplementation(() => Promise.resolve('nope'));

await runner.runner(options);

expect(runner.promptProduction).toHaveBeenCalledTimes(1);
expect(options.commands.test).toHaveBeenCalledTimes(0);
expect(consola.warn).toHaveBeenCalledTimes(0);
expect(consola.error).toHaveBeenCalledTimes(1);
});

it(`accepts '${runner.CONFIRM_PRODUCTION}'`, async () => {
const options = getOptions({ stage: 'production' });
jest.spyOn(consola, 'info').mockImplementation();
jest.spyOn(consola, 'warn').mockImplementation();
jest.spyOn(consola, 'error').mockImplementation();
jest.spyOn(runner, 'promptProduction').mockImplementation(() => Promise.resolve(runner.CONFIRM_PRODUCTION));

await runner.runner(options);

expect(runner.promptProduction).toHaveBeenCalledTimes(1);
expect(options.commands.test).toHaveBeenCalledTimes(1);
expect(consola.warn).toHaveBeenCalledTimes(1);
expect(consola.error).toHaveBeenCalledTimes(0);
});
});
});
});

0 comments on commit c21d24b

Please sign in to comment.