Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support template-path and name CLI options for interactive CLI #9471

Merged
merged 5 commits into from
May 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/cli/commands-schema/no-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ commands.set('', {
hasAwsExtension: true,
options: {
'help-interactive': { usage: 'Show this message', type: 'boolean' },
'template-path': {
usage: 'Template local path for the service.',
},
'name': {
usage: 'Name for the service.',
},
},
lifecycleEvents: ['initializeService', 'setupAws', 'autoUpdate', 'tabCompletion', 'end'],
});
Expand Down
96 changes: 75 additions & 21 deletions lib/cli/interactive-setup/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const readConfiguration = require('../../configuration/read');
const createFromTemplate = require('../../utils/createFromTemplate');
const resolveVariables = require('../../configuration/variables');
const { confirm } = require('./utils');
const createFromLocalTemplate = require('../../utils/create-from-local-template');
const ServerlessError = require('../../serverless-error');

const isValidServiceName = RegExp.prototype.test.bind(/^[a-zA-Z][a-zA-Z0-9-]{0,100}$/);

Expand All @@ -28,6 +30,12 @@ const projectTypeChoice = async () =>
})
).projectType;

const INVALID_PROJECT_NAME_MESSAGE =
'Project name is not valid.\n' +
' - It should only contain alphanumeric and hyphens.\n' +
' - It should start with an alphabetic character.\n' +
" - Shouldn't exceed 128 characters";

const projectNameInput = async (workingDir) =>
(
await inquirer.prompt({
Expand All @@ -37,12 +45,7 @@ const projectNameInput = async (workingDir) =>
validate: async (input) => {
input = input.trim();
if (!isValidServiceName(input)) {
return (
'Project name is not valid.\n' +
' - It should only contain alphanumeric and hyphens.\n' +
' - It should start with an alphabetic character.\n' +
" - Shouldn't exceed 128 characters"
);
return INVALID_PROJECT_NAME_MESSAGE;
}

try {
Expand All @@ -55,27 +58,78 @@ const projectNameInput = async (workingDir) =>
})
).projectName.trim();

const resolveProjectNameInput = async (options, workingDir) => {
if (options.name) {
if (!isValidServiceName(options.name)) {
throw new ServerlessError(INVALID_PROJECT_NAME_MESSAGE, 'INVALID_PROJECT_NAME');
}

let alreadyTaken = false;
try {
await fs.promises.access(join(workingDir, options.name));
alreadyTaken = true;
} catch {
// Pass
}

if (alreadyTaken) {
throw new ServerlessError(
`Path ${options.name} is already taken`,
'TARGET_FOLDER_ALREADY_EXISTS'
);
}

return options.name;
}

return projectNameInput(workingDir);
};

module.exports = {
isApplicable({ serviceDir }) {
isApplicable({ options, serviceDir }) {
if (serviceDir && (options.name || options['template-path'])) {
throw new ServerlessError(
'Cannot setup a new service when being in context of another service ("--name" and "--template-path" options cannot be applied)',
'NOT_APPLICABLE_SERVICE_OPTIONS'
);
}

return !serviceDir;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also throw here errors, if given options were passed but user is in service context (we should not silently ignore them)

},
async run(context) {
const workingDir = context.cwd || process.cwd();
const isConfirmed = await confirm('No project detected. Do you want to create a new one?', {
name: 'shouldCreateNewProject',
});
if (!isConfirmed) return;
const projectType = await projectTypeChoice();
if (projectType === 'other') {
process.stdout.write(
'\nRun “serverless create --help” to view available templates and create a new project ' +
'from one of those templates.\n'
);
return;

if (!context.options.name && !context.options['template-path']) {
const isConfirmed = await confirm('No project detected. Do you want to create a new one?', {
name: 'shouldCreateNewProject',
});
if (!isConfirmed) return;
}

let projectDir;
let projectName;
if (context.options['template-path']) {
projectName = await resolveProjectNameInput(context.options, workingDir);
projectDir = join(workingDir, projectName);
await createFromLocalTemplate({
templatePath: context.options['template-path'],
projectDir,
projectName,
});
} else {
const projectType = await projectTypeChoice();
if (projectType === 'other') {
process.stdout.write(
'\nRun “serverless create --help” to view available templates and create a new project ' +
'from one of those templates.\n'
);
return;
}
projectName = await resolveProjectNameInput(context.options, workingDir);
projectDir = join(workingDir, projectName);
await createFromTemplate(projectType, projectDir);
}
const projectName = await projectNameInput(workingDir);
const projectDir = join(workingDir, projectName);
await createFromTemplate(projectType, projectDir);

process.stdout.write(
`\n${chalk.green(`Project successfully created in '${projectName}' folder.`)}\n`
);
Expand Down
34 changes: 34 additions & 0 deletions lib/utils/create-from-local-template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

const untildify = require('untildify');
const fse = require('fs-extra');
const { renameService } = require('./renameService');

const ServerlessError = require('../serverless-error');

module.exports = async ({ templatePath, projectDir, projectName }) => {
const sourcePath = untildify(templatePath);

try {
await fse.copy(sourcePath, projectDir, {
dereference: true,
filter: async (src) => {
const stats = await fse.lstat(src);
return !stats.isSymbolicLink();
},
});
} catch (err) {
if (err.code === 'ENOENT') {
throw new ServerlessError(
`Could not find a template under provider path: ${sourcePath}`,
'INVALID_TEMPLATE_PATH'
);
}

throw new ServerlessError(`Cannot copy template: ${err.message}`, 'COPY_LOCAL_TEMPLATE_ERROR');
}

if (projectName) {
renameService(projectName, projectDir);
}
};
64 changes: 57 additions & 7 deletions test/unit/lib/cli/interactive-setup/service.test.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
'use strict';

const chai = require('chai');
const path = require('path');
const sinon = require('sinon');
const configureInquirerStub = require('@serverless/test/configure-inquirer-stub');
const step = require('../../../../../lib/cli/interactive-setup/service');

const templatesPath = path.resolve(__dirname, '../../../../../lib/plugins/create/templates');

const { expect } = chai;

chai.use(require('chai-as-promised'));
Expand All @@ -19,15 +22,23 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
afterEach(() => sinon.restore());

it('Should be not applied, when at service path', () =>
expect(step.isApplicable({ serviceDir: '/foo' })).to.equal(false));
expect(step.isApplicable({ serviceDir: '/foo', options: {} })).to.equal(false));
it('Should be applied, when not at service path', () =>
expect(step.isApplicable({})).to.equal(true));
expect(step.isApplicable({ options: {} })).to.equal(true));

it('Should result in an error when at service path with `template-path` options provided', () => {
expect(() =>
step.isApplicable({ serviceDir: '/foo', options: { 'template-path': 'path/to/template' } })
)
.to.throw()
.and.have.property('code', 'NOT_APPLICABLE_SERVICE_OPTIONS');
});

it("Should abort if user doesn't want setup", async () => {
configureInquirerStub(inquirer, {
confirm: { shouldCreateNewProject: false },
});
await step.run({});
await step.run({ options: {} });
return confirmEmptyWorkingDir();
});

Expand All @@ -36,7 +47,7 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
confirm: { shouldCreateNewProject: true },
list: { projectType: 'other' },
});
await step.run({});
await step.run({ options: {} });
return confirmEmptyWorkingDir();
});

Expand All @@ -47,10 +58,28 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {
list: { projectType: 'aws-nodejs' },
input: { projectName: 'test-project' },
});
await step.run({});
await step.run({ options: {} });
const stats = await fsp.lstat('test-project/serverless.yml');
expect(stats.isFile()).to.be.true;
});

it('Should create project at not existing directory from a provided `template-path`', async () => {
configureInquirerStub(inquirer, {
input: { projectName: 'test-project-from-local-template' },
});
await step.run({ options: { 'template-path': path.join(templatesPath, 'aws-nodejs') } });
const stats = await fsp.lstat('test-project-from-local-template/serverless.yml');
expect(stats.isFile()).to.be.true;
});

it('Should create project at not existing directory with provided `name`', async () => {
configureInquirerStub(inquirer, {
list: { projectType: 'aws-nodejs' },
});
await step.run({ options: { name: 'test-project-from-cli-option' } });
const stats = await fsp.lstat('test-project-from-cli-option/serverless.yml');
expect(stats.isFile()).to.be.true;
});
});

it('Should not allow project creation in a directory in which already service is configured', async () => {
Expand All @@ -62,21 +91,42 @@ describe('test/unit/lib/cli/interactive-setup/service.test.js', () => {

await fsp.mkdir('existing');

await expect(step.run({})).to.eventually.be.rejected.and.have.property(
await expect(step.run({ options: {} })).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_ANSWER'
);
});

it('Should not allow project creation in a directory in which already service is configured when `name` flag provided', async () => {
configureInquirerStub(inquirer, {
list: { projectType: 'aws-nodejs' },
});

await fsp.mkdir('anotherexisting');

await expect(
step.run({ options: { name: 'anotherexisting' } })
).to.eventually.be.rejected.and.have.property('code', 'TARGET_FOLDER_ALREADY_EXISTS');
});

it('Should not allow project creation using an invalid project name', async () => {
configureInquirerStub(inquirer, {
confirm: { shouldCreateNewProject: true },
list: { projectType: 'aws-nodejs' },
input: { projectName: 'elo grzegżółka' },
});
await expect(step.run({})).to.eventually.be.rejected.and.have.property(
await expect(step.run({ options: {} })).to.eventually.be.rejected.and.have.property(
'code',
'INVALID_ANSWER'
);
});

it('Should not allow project creation using an invalid project name when `name` flag provided', async () => {
configureInquirerStub(inquirer, {
list: { projectType: 'aws-nodejs' },
});
await expect(
step.run({ options: { name: 'elo grzegżółka' } })
).to.eventually.be.rejected.and.have.property('code', 'INVALID_PROJECT_NAME');
});
});
63 changes: 63 additions & 0 deletions test/unit/lib/utils/create-from-local-template.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use strict';

const path = require('path');
const chai = require('chai');
const fsp = require('fs').promises;
const { load: yamlParse } = require('js-yaml');
const createFromLocalTemplate = require('../../../../lib/utils/create-from-local-template');
const { getTmpDirPath } = require('../../../utils/fs');

const templatesPath = path.resolve(__dirname, '../../../../lib/plugins/create/templates');

chai.use(require('chai-as-promised'));

const expect = chai.expect;

describe('test/unit/lib/utils/create-from-local-template.test.js', () => {
describe('Without `projectName` provided', () => {
it('should create from template referenced locally', async () => {
const tmpDirPath = path.join(getTmpDirPath(), 'some-service');
await createFromLocalTemplate({
templatePath: path.join(templatesPath, 'aws-nodejs'),
projectDir: tmpDirPath,
});
const stats = await fsp.lstat(path.join(tmpDirPath, 'serverless.yml'));
expect(stats.isFile()).to.be.true;
});
});

describe('When `templatePath` does not exist', () => {
it('should result in an error', async () => {
const tmpDirPath = path.join(getTmpDirPath(), 'some-service');
await expect(
createFromLocalTemplate({
templatePath: path.join(templatesPath, 'nonexistent'),
projectDir: tmpDirPath,
})
).to.eventually.be.rejected.and.have.property('code', 'INVALID_TEMPLATE_PATH');
});
});

describe('With `projectName` provided', () => {
let tmpDirPath;

before(async () => {
tmpDirPath = path.join(getTmpDirPath(), 'some-service');
await createFromLocalTemplate({
templatePath: path.join(templatesPath, 'fn-nodejs'),
projectDir: tmpDirPath,
projectName: 'testproj',
});
});

it('should set service name in serverless.yml', async () =>
expect(
yamlParse(await fsp.readFile(path.join(tmpDirPath, 'serverless.yml'))).service
).to.equal('testproj'));

it('should set name in package.json', async () =>
expect(JSON.parse(await fsp.readFile(path.join(tmpDirPath, 'package.json'))).name).to.equal(
'testproj'
));
});
});