Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/plugins/create/create.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

const path = require('path');
const untildify = require('untildify');
const untildify = require('../../utils/untildify');

const ServerlessError = require('../../serverless-error');
const cliCommandsSchema = require('../../cli/commands-schema');
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/create-from-local-template.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

const untildify = require('untildify');
const untildify = require('./untildify');
const fsp = require('fs').promises;
const copy = require('./fs/copy');
const { renameService } = require('./rename-service');
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/download-template-from-repo.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const URL = require('url');
const download = require('./serverless-utils/download');
const qs = require('querystring');
const spawn = require('./spawn');
const untildify = require('untildify');
const untildify = require('./untildify');
const renameService = require('./rename-service').renameService;
const ServerlessError = require('../serverless-error');
const copyDirContentsSync = require('./fs/copy-dir-contents-sync');
Expand Down
75 changes: 75 additions & 0 deletions lib/utils/untildify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

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

let homeDirectory;
let currentUser;

const getHomeDirectory = (inputPath) => {
if (homeDirectory === undefined) {
try {
homeDirectory = os.homedir() || null;
} catch (error) {
throw new ServerlessError(
`Cannot expand path "${inputPath}": home directory could not be resolved: ${error.message}`,
'HOME_DIRECTORY_UNAVAILABLE'
);
}
}

if (homeDirectory) return homeDirectory;

throw new ServerlessError(
`Cannot expand path "${inputPath}": home directory could not be resolved.`,
'HOME_DIRECTORY_UNAVAILABLE'
);
};

const getCurrentUser = (inputPath) => {
if (currentUser === undefined) {
try {
const userInfo = os.userInfo();
currentUser = userInfo && userInfo.username ? userInfo.username : null;
} catch (error) {
throw new ServerlessError(
`Cannot expand path "${inputPath}": current user could not be resolved: ${error.message}`,
'CURRENT_USER_UNAVAILABLE'
);
}
}

if (currentUser) return currentUser;

throw new ServerlessError(
`Cannot expand path "${inputPath}": current user could not be resolved.`,
'CURRENT_USER_UNAVAILABLE'
);
};

module.exports = (pathWithTilde) => {
if (typeof pathWithTilde !== 'string') {
throw new TypeError(`Expected a string, got ${typeof pathWithTilde}`);
}

if (!pathWithTilde.startsWith('~')) {
return pathWithTilde;
}

if (/^~(?=$|\/|\\)/.test(pathWithTilde)) {
return `${getHomeDirectory(pathWithTilde)}${pathWithTilde.slice(1)}`;
}

const userMatch = pathWithTilde.match(/^~([^/\\]+)(.*)/);
const username = userMatch[1];
const rest = userMatch[2];

if (username !== getCurrentUser(pathWithTilde)) {
throw new ServerlessError(
`Cannot expand path "${pathWithTilde}": user-home expansion is only supported for the current user.`,
'UNSUPPORTED_HOME_DIRECTORY_EXPANSION'
);
}

return `${getHomeDirectory(pathWithTilde)}${rest}`;
};
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@
"tsx": "^4.20.3",
"type": "^2.7.2",
"undici": "^7.25.0",
"untildify": "^4.0.0",
"write-file-atomic": "^7.0.1",
"yauzl": "^3.3.0",
"yazl": "^3.3.1"
Expand Down
94 changes: 91 additions & 3 deletions test/unit/lib/plugins/create/create.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const http = require('http');
const fsp = require('fs').promises;
const os = require('os');
const path = require('path');
const AdmZip = require('adm-zip');
const proxyquire = require('proxyquire');
Expand All @@ -14,13 +15,13 @@ const { expect } = require('chai');

const fixturesPath = path.resolve(__dirname, '../../../../fixtures/programmatic');

const loadCreate = ({ downloadTemplateFromRepoStub, dirExistsSyncStub } = {}) => {
const loadCreate = ({ downloadTemplateFromRepoStub, dirExistsSyncStub, untildifyStub } = {}) => {
const noticeStub = sinon.stub();
noticeStub.success = sinon.stub();
const copyDirContentsSyncStub = sinon.stub();
const renameServiceStub = sinon.stub();

const Create = proxyquire.noCallThru().load('../../../../../lib/plugins/create/create', {
const stubs = {
'../../utils/download-template-from-repo': {
downloadTemplateFromRepo: downloadTemplateFromRepoStub || sinon.stub(),
},
Expand All @@ -40,7 +41,10 @@ const loadCreate = ({ downloadTemplateFromRepoStub, dirExistsSyncStub } = {}) =>
aside: () => '',
},
},
});
};
if (untildifyStub) stubs['../../utils/untildify'] = untildifyStub;

const Create = proxyquire.noCallThru().load('../../../../../lib/plugins/create/create', stubs);

return {
Create,
Expand Down Expand Up @@ -294,6 +298,62 @@ describe('test/unit/lib/plugins/create/create.test.js', () => {
);
});

it('should expand a home-relative local template path before copying', async () => {
const { Create, copyDirContentsSyncStub, noticeSuccessStub, renameServiceStub } =
loadCreate();

await new Create(
{
pluginManager: {
commandRunStartTime: Date.now(),
},
},
{
'template-path': '~/aws',
}
).create();

expect(copyDirContentsSyncStub.calledOnce).to.equal(true);
expect(path.normalize(copyDirContentsSyncStub.firstCall.args[0])).to.equal(
path.join(os.homedir(), 'aws')
);
expect(copyDirContentsSyncStub.firstCall.args[1]).to.equal(path.join(process.cwd(), 'aws'));
expect(renameServiceStub.called).to.equal(false);
expect(noticeSuccessStub.firstCall.args[0]).to.contain(
'Project successfully created in "./aws"'
);
});

it('should stop before copying when local template path expansion fails', async () => {
const expansionError = new ServerlessError(
'Cannot expand path "~/aws": home directory could not be resolved.',
'HOME_DIRECTORY_UNAVAILABLE'
);
const { Create, copyDirContentsSyncStub, renameServiceStub } = loadCreate({
untildifyStub: sinon.stub().throws(expansionError),
});

try {
await new Create(
{
pluginManager: {
commandRunStartTime: Date.now(),
},
},
{
'template-path': '~/aws',
}
).create();
} catch (error) {
expect(error).to.equal(expansionError);
expect(copyDirContentsSyncStub).to.not.have.been.called;
expect(renameServiceStub).to.not.have.been.called;
return;
}

throw new Error('Expected create() to reject');
});

it('should default the service name to the target directory basename when only --path is provided', async () => {
const { Create, copyDirContentsSyncStub, noticeSuccessStub, renameServiceStub } =
loadCreate();
Expand Down Expand Up @@ -324,6 +384,34 @@ describe('test/unit/lib/plugins/create/create.test.js', () => {
);
});

it('should expand a home-relative local target path while reporting the original path', async () => {
const { Create, copyDirContentsSyncStub, noticeSuccessStub, renameServiceStub } =
loadCreate();
const targetPath = '~/nested/service-directory';
const expectedServiceDir = path.join(os.homedir(), 'nested', 'service-directory');

await new Create(
{
pluginManager: {
commandRunStartTime: Date.now(),
},
},
{
'template-path': path.join(fixturesPath, 'aws'),
'path': targetPath,
}
).create();

expect(copyDirContentsSyncStub.calledOnce).to.equal(true);
expect(copyDirContentsSyncStub.firstCall.args[1]).to.equal(expectedServiceDir);
expect(
renameServiceStub.calledOnceWithExactly('service-directory', expectedServiceDir)
).to.equal(true);
expect(noticeSuccessStub.firstCall.args[0]).to.contain(
'Project successfully created in "~/nested/service-directory"'
);
});

it('should report the provided local target path when the directory already exists', async () => {
const { Create } = loadCreate({
dirExistsSyncStub: sinon.stub().returns(true),
Expand Down
67 changes: 67 additions & 0 deletions test/unit/lib/utils/create-from-local-template.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@ const path = require('path');
const chai = require('chai');
const fs = require('fs');
const fsp = require('fs').promises;
const proxyquire = require('proxyquire').noCallThru().noPreserveCache();
const sinon = require('sinon');
const { load: yamlParse } = require('js-yaml');
const createFromLocalTemplate = require('../../../../lib/utils/create-from-local-template');
const ServerlessError = require('../../../../lib/serverless-error');
const { ensureDir, getTmpDirPath, outputFile, pathExists, remove } = require('../../../utils/fs');
const skipOnDisabledSymlinksInWindows = require('../../../lib/skip-on-disabled-symlinks-in-windows');

const fixturesPath = path.resolve(__dirname, '../../../fixtures/programmatic');

const expect = chai.expect;

const loadUntildifyWithHome = (homeDirectory) =>
proxyquire('../../../../lib/utils/untildify', {
os: {
homedir: () => homeDirectory,
userInfo: () => ({ username: 'test-user' }),
},
});

describe('test/unit/lib/utils/create-from-local-template.test.js', () => {
describe('Without `projectName` provided', () => {
it('should create from template referenced locally', async () => {
Expand All @@ -25,6 +36,62 @@ describe('test/unit/lib/utils/create-from-local-template.test.js', () => {
expect(stats.isFile()).to.be.true;
});

it('should expand a home-relative template path', async () => {
const fakeHome = getTmpDirPath();
const templateDirName = 'test-template';
const homeTemplatePath = path.join(fakeHome, templateDirName);
const projectDir = path.join(getTmpDirPath(), 'some-service');
const createFromLocalTemplateWithFakeHome = proxyquire(
'../../../../lib/utils/create-from-local-template',
{
'./untildify': loadUntildifyWithHome(fakeHome),
}
);

try {
await outputFile(path.join(homeTemplatePath, 'serverless.yml'), 'service: source\n');

await createFromLocalTemplateWithFakeHome({
templatePath: `~/${templateDirName}`,
projectDir,
});

const stats = await fsp.lstat(path.join(projectDir, 'serverless.yml'));
expect(stats.isFile()).to.equal(true);
} finally {
await remove(fakeHome);
await remove(projectDir);
}
});

it('should stop before copying when template path expansion fails', async () => {
const expansionError = new ServerlessError(
'Cannot expand path "~/template": home directory could not be resolved.',
'HOME_DIRECTORY_UNAVAILABLE'
);
const copyStub = sinon.stub().resolves();
const createFromLocalTemplateWithFailingUntildify = proxyquire(
'../../../../lib/utils/create-from-local-template',
{
'./untildify': sinon.stub().throws(expansionError),
'./fs/copy': copyStub,
}
);

try {
await createFromLocalTemplateWithFailingUntildify({
templatePath: '~/template',
projectDir: path.join(getTmpDirPath(), 'some-service'),
});
} catch (error) {
expect(error).to.equal(expansionError);
expect(copyStub).to.not.have.been.called;
return;
}

throw new Error('Expected createFromLocalTemplate to reject');
});

it('skips symlinks when creating from a local template', async function () {
const tmpRoot = getTmpDirPath();
const templatePath = path.join(tmpRoot, 'template');
Expand Down
Loading