104 changes: 38 additions & 66 deletions packages/strapi/bin/strapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,11 @@
const _ = require('lodash');
const resolveCwd = require('resolve-cwd');
const { yellow } = require('chalk');
const program = require('commander');
const { Command } = require('commander');
const program = new Command();

const packageJSON = require('../package.json');

// Allow us to display `help()`, but omit the wildcard (`*`) command.
program.Command.prototype.usageMinusWildcard = program.usageMinusWildcard = () => {
program.commands = _.reject(program.commands, {
_name: '*',
});
program.help();
};

const checkCwdIsStrapiApp = name => {
let logErrorAndExit = () => {
console.log(
Expand Down Expand Up @@ -61,37 +54,29 @@ const getLocalScript = name => (...args) => {
});
};

/**
* Normalize version argument
*
* `$ strapi -v`
* `$ strapi -V`
* `$ strapi --version`
* `$ strapi version`
*/

program.allowUnknownOption(true);

// Expose version.
program.version(packageJSON.version, '-v, --version');
// Initial program setup
program
.storeOptionsAsProperties(false)
.passCommandToAction(false)
.allowUnknownOption(true);

// Make `-v` option case-insensitive.
process.argv = _.map(process.argv, arg => {
return arg === '-V' ? '-v' : arg;
});
program.helpOption('-h, --help', 'Display help for command');
program.addHelpCommand('help [command]', 'Display help for command');

// `$ strapi version` (--version synonym)
program.option('-v, --version', 'Output the version number');
program
.command('version')
.description('output your version of Strapi')
.description('Output your version of Strapi')
.action(() => {
console.log(packageJSON.version);
process.stdout.write(packageJSON.version + '\n');
process.exit(0);
});

// `$ strapi console`
program
.command('console')
.description('open the Strapi framework console')
.description('Open the Strapi framework console')
.action(getLocalScript('console'));

// `$ strapi new`
Expand All @@ -112,7 +97,7 @@ program
.option('--dbauth <dbauth>', 'Authentication Database')
.option('--dbfile <dbfile>', 'Database file path for sqlite')
.option('--dbforce', 'Overwrite database content if any')
.description('create a new application')
.description('Create a new application')
.action(require('../lib/commands/new'));

// `$ strapi start`
Expand All @@ -125,8 +110,8 @@ program
program
.command('develop')
.alias('dev')
.option('--no-build', 'Disable build', false)
.option('--watch-admin', 'Enable watch', true)
.option('--no-build', 'Disable build')
.option('--watch-admin', 'Enable watch', false)
.option('--browser <name>', 'Open the browser', true)
.description('Start your Strapi application in development mode')
.action(getLocalScript('develop'));
Expand All @@ -138,8 +123,8 @@ program
.option('-p, --plugin <api>', 'Name of the local plugin')
.option('-e, --extend <api>', 'Name of the plugin to extend')
.option('-c, --connection <connection>', 'The name of the connection to use')
.option('--draft-and-publish <value>', 'Enable draft/publish', false)
.description('generate a basic API')
.option('--draft-and-publish', 'Enable draft/publish', false)
.description('Generate a basic API')
.action((id, attributes, cliArguments) => {
cliArguments.attributes = attributes;
getLocalScript('generate')(id, cliArguments);
Expand All @@ -151,7 +136,7 @@ program
.option('-a, --api <api>', 'API name to generate the files in')
.option('-p, --plugin <api>', 'Name of the local plugin')
.option('-e, --extend <api>', 'Name of the plugin to extend')
.description('generate a controller for an API')
.description('Generate a controller for an API')
.action(getLocalScript('generate'));

// `$ strapi generate:model`
Expand All @@ -160,8 +145,8 @@ program
.option('-a, --api <api>', 'API name to generate a sub API')
.option('-p, --plugin <api>', 'plugin name')
.option('-c, --connection <connection>', 'The name of the connection to use')
.option('--draft-and-publish <value>', 'Enable draft/publish', false)
.description('generate a model for an API')
.option('--draft-and-publish', 'Enable draft/publish', false)
.description('Generate a model for an API')
.action((id, attributes, cliArguments) => {
cliArguments.attributes = attributes;
getLocalScript('generate')(id, cliArguments);
Expand All @@ -172,7 +157,7 @@ program
.command('generate:policy <id>')
.option('-a, --api <api>', 'API name')
.option('-p, --plugin <api>', 'plugin name')
.description('generate a policy for an API')
.description('Generate a policy for an API')
.action(getLocalScript('generate'));

// `$ strapi generate:service`
Expand All @@ -181,33 +166,33 @@ program
.option('-a, --api <api>', 'API name')
.option('-p, --plugin <api>', 'plugin name')
.option('-t, --tpl <template>', 'template name')
.description('generate a service for an API')
.description('Generate a service for an API')
.action(getLocalScript('generate'));

// `$ strapi generate:plugin`
program
.command('generate:plugin <id>')
.option('-n, --name <name>', 'Plugin name')
.description('generate a basic plugin')
.description('Generate a basic plugin')
.action(getLocalScript('generate'));

program
.command('build')
.option('--clean', 'Remove the build and .cache folders', false)
.option('--no-optimization', 'Build the Administration without assets optimization', false)
.option('--no-optimization', 'Build the Administration without assets optimization')
.description('Builds the strapi admin app')
.action(getLocalScript('build'));

// `$ strapi install`
program
.command('install [plugins...]')
.description('install a Strapi plugin')
.description('Install a Strapi plugin')
.action(getLocalScript('install'));

// `$ strapi uninstall`
program
.command('uninstall [plugins...]')
.description('uninstall a Strapi plugin')
.description('Uninstall a Strapi plugin')
.option('-d, --delete-files', 'Delete files', false)
.action(getLocalScript('uninstall'));

Expand All @@ -221,38 +206,25 @@ program
program
.command('configuration:dump')
.alias('config:dump')
.description('Dump configurations of your application')
.option('-f, --file <file>', 'Output file, default output is stdout')
.action(getLocalScript('configurationDump'));

program
.command('configuration:restore')
.alias('config:restore')
.description('Restore configurations of your application')
.option('-f, --file <file>', 'Input file, default input is stdin')
.option('-s, --strategy <strategy>', 'Strategy name, one of: "replace", "merge", "keep"')
.action(getLocalScript('configurationRestore'));

/**
* Normalize help argument
*/

// `$ strapi help` (--help synonym)
// Admin
program
.command('help')
.description('output the help')
.action(program.usageMinusWildcard);

// `$ strapi <unrecognized_cmd>`
// Mask the '*' in `help`.
program.command('*').action(program.usageMinusWildcard);

// Don't balk at unknown options.

/**
* `$ strapi`
*/
.command('admin:reset-user-password')
.alias('admin:reset-password')
.description("Reset an admin user's password")
.option('-e, --email <email>', 'The user email')
.option('-p, --password <password>', 'New password for the user')
.action(getLocalScript('admin-reset'));

program.parse(process.argv);
const NO_COMMAND_SPECIFIED = program.args.length === 0;
if (NO_COMMAND_SPECIFIED) {
program.usageMinusWildcard();
}
program.parseAsync(process.argv);
141 changes: 141 additions & 0 deletions packages/strapi/lib/commands/__tests__/admin-reset.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use strict';

const load = jest.fn(() => mock);
const resetPasswordByEmail = jest.fn();
const admin = {
services: {
user: {
resetPasswordByEmail,
},
},
};

const mock = {
load,
admin,
};

jest.mock('../../index', () => {
return jest.fn(() => mock);
});

const inquirer = require('inquirer');
const resetAdminPasswordCommand = require('../admin-reset');

describe('admin:reset-password command', () => {
beforeEach(() => {
load.mockClear();
resetPasswordByEmail.mockClear();
});

test('resetAdminPasswordCommand accepts direct input', async () => {
const email = 'email@email.fr';
const password = 'testPasword1234';

const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});

await resetAdminPasswordCommand({ email, password });

expect(mockExit).toHaveBeenCalledWith(0);
expect(consoleLog).toHaveBeenCalled();
expect(load).toHaveBeenCalled();
expect(resetPasswordByEmail).toHaveBeenCalledWith(email, password);

mockExit.mockRestore();
consoleLog.mockRestore();
});

describe('Handles prompt input', () => {
test('Only prompt on TTY', async () => {
const tmpTTY = process.stdin.isTTY;
process.stdin.isTTY = false;

// throw so the code will stop executing
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit');
});

const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});

await resetAdminPasswordCommand().catch(err => {
expect(err).toEqual(new Error('exit'));
});

expect(consoleError).toBeCalledWith('Missing required options `email` or `password`');
expect(mockExit).toHaveBeenCalledWith(1);
expect(load).not.toHaveBeenCalled();
expect(resetPasswordByEmail).not.toHaveBeenCalled();

process.stdin.isTTY = tmpTTY;
mockExit.mockRestore();
consoleError.mockRestore();
});

test('Stops if not confirmed', async () => {
process.stdin.isTTY = true;
const email = 'email@email.fr';
const password = 'testPasword1234';

const mockInquiry = jest
.spyOn(inquirer, 'prompt')
.mockImplementationOnce(async () => ({ email, password, confirm: false }));

// throw so the code will stop executing
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit');
});

await resetAdminPasswordCommand().catch(err => {
expect(err).toEqual(new Error('exit'));
});

expect(mockInquiry).toHaveBeenLastCalledWith([
expect.objectContaining({
message: expect.any(String),
name: 'email',
type: 'input',
}),
expect.objectContaining({
message: expect.any(String),
name: 'password',
type: 'password',
}),
expect.objectContaining({
message: expect.any(String),
name: 'confirm',
type: 'confirm',
}),
]);
expect(mockExit).toHaveBeenCalledWith(0);
expect(load).not.toHaveBeenCalled();
expect(resetPasswordByEmail).not.toHaveBeenCalled();

mockExit.mockRestore();
mockInquiry.mockRestore();
});

test('Calls the reset method with user input', async () => {
const email = 'email@email.fr';
const password = 'testPasword1234';

const mockInquiry = jest
.spyOn(inquirer, 'prompt')
.mockImplementationOnce(async () => ({ email, password, confirm: true }));

const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});

await resetAdminPasswordCommand();

expect(mockExit).toHaveBeenCalledWith(0);
expect(consoleLog).toHaveBeenCalled();
expect(load).toHaveBeenCalled();
expect(resetPasswordByEmail).toHaveBeenCalledWith(email, password);

mockInquiry.mockRestore();
mockExit.mockRestore();
consoleLog.mockRestore();
});
});
});
51 changes: 51 additions & 0 deletions packages/strapi/lib/commands/admin-reset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const _ = require('lodash');
const inquirer = require('inquirer');
const strapi = require('../index');

const promptQuestions = [
{ type: 'input', name: 'email', message: 'User email?' },
{ type: 'password', name: 'password', message: 'New password?' },
{
type: 'confirm',
name: 'confirm',
message: "Do you really want to reset this user's password?",
},
];

/**
* Reset user's password
* @param {Object} cmdOptions - command options
* @param {string} cmdOptions.email - user's email
* @param {string} cmdOptions.password - user's new password
*/
module.exports = async function(cmdOptions = {}) {
const { email, password } = cmdOptions;

if (_.isEmpty(email) && _.isEmpty(password) && process.stdin.isTTY) {
const inquiry = await inquirer.prompt(promptQuestions);

if (!inquiry.confirm) {
process.exit(0);
}

return changePassword(inquiry);
}

if (_.isEmpty(email) || _.isEmpty(password)) {
console.error('Missing required options `email` or `password`');
process.exit(1);
}

return changePassword({ email, password });
};

async function changePassword({ email, password }) {
const app = await strapi().load();

await app.admin.services.user.resetPasswordByEmail(email, password);

console.log(`Successfully reset user's password`);
process.exit(0);
}
2 changes: 1 addition & 1 deletion packages/strapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"chokidar": "3.3.1",
"ci-info": "2.0.0",
"cli-table3": "^0.6.0",
"commander": "^2.20.0",
"commander": "6.1.0",
"cross-spawn": "^6.0.5",
"debug": "^4.1.1",
"delegates": "^1.0.0",
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5651,6 +5651,11 @@ commander@2.3.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873"
integrity sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM=

commander@6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc"
integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA==

commander@^2.19.0, commander@^2.20.0, commander@^2.20.3:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
Expand Down