Skip to content

Commit

Permalink
Merge pull request #1133 from HubSpot/jy/poc-hs-project-install
Browse files Browse the repository at this point in the history
feat: Add `hs project install-deps` command
  • Loading branch information
brandenrodgers authored Sep 9, 2024
2 parents b533db6 + 2a9e783 commit c662b17
Show file tree
Hide file tree
Showing 7 changed files with 769 additions and 0 deletions.
105 changes: 105 additions & 0 deletions packages/cli/commands/__tests__/projects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
const { command, describe: projectDescribe, builder } = require('../project');

jest.mock('../project/deploy');
jest.mock('../project/create');
jest.mock('../project/upload');
jest.mock('../project/listBuilds');
jest.mock('../project/logs');
jest.mock('../project/watch');
jest.mock('../project/download');
jest.mock('../project/open');
jest.mock('../project/dev');
jest.mock('../project/add');
jest.mock('../project/migrateApp');
jest.mock('../project/cloneApp');
jest.mock('../project/installDeps');
jest.mock('../../lib/commonOpts');

const deploy = require('../project/deploy');
const create = require('../project/create');
const upload = require('../project/upload');
const listBuilds = require('../project/listBuilds');
const logs = require('../project/logs');
const watch = require('../project/watch');
const download = require('../project/download');
const open = require('../project/open');
const dev = require('../project/dev');
const add = require('../project/add');
const migrateApp = require('../project/migrateApp');
const cloneApp = require('../project/cloneApp');
const installDeps = require('../project/installDeps');
const { addConfigOptions, addAccountOptions } = require('../../lib/commonOpts');

describe('commands/projects', () => {
describe('command', () => {
it('should have the correct command structure', () => {
expect(command).toEqual('project');
});
});

describe('describe', () => {
it('should contain the beta tag', () => {
expect(projectDescribe).toContain('[BETA]');
});
it('should provide an accurate description of what the command is doing', () => {
expect(projectDescribe).toContain(
'Commands for working with projects. For more information, visit our documentation: https://developers.hubspot.com/docs/platform/build-and-deploy-using-hubspot-projects'
);
});
});

describe('builder', () => {
let yargs;

const subcommands = [
['create', create],
['add', add],
['watch', watch],
['dev', dev],
['upload', upload],
['deploy', deploy],
['logs', logs],
['listBuilds', listBuilds],
['download', download],
['open', open],
['migrateApp', migrateApp],
['cloneApp', cloneApp],
['installDeps', installDeps],
];

beforeEach(() => {
yargs = {
command: jest.fn().mockImplementation(() => yargs),
demandCommand: jest.fn().mockImplementation(() => yargs),
};
});

it('should add the config options', () => {
builder(yargs);
expect(addConfigOptions).toHaveBeenCalledTimes(1);
expect(addConfigOptions).toHaveBeenCalledWith(yargs);
});

it('should add the account options', () => {
builder(yargs);
expect(addAccountOptions).toHaveBeenCalledTimes(1);
expect(addAccountOptions).toHaveBeenCalledWith(yargs);
});

it('should add the correct number of sub commands', () => {
builder(yargs);
expect(yargs.command).toHaveBeenCalledTimes(subcommands.length);
});

it.each(subcommands)('should attach the %s subcommand', (name, module) => {
builder(yargs);
expect(yargs.command).toHaveBeenCalledWith(module);
});

it('should demand the command takes one positional argument', () => {
builder(yargs);
expect(yargs.demandCommand).toHaveBeenCalledTimes(1);
expect(yargs.demandCommand).toHaveBeenCalledWith(1, '');
});
});
});
2 changes: 2 additions & 0 deletions packages/cli/commands/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const dev = require('./project/dev');
const add = require('./project/add');
const migrateApp = require('./project/migrateApp');
const cloneApp = require('./project/cloneApp');
const installDeps = require('./project/installDeps');

const i18nKey = 'commands.project';

Expand All @@ -36,6 +37,7 @@ exports.builder = yargs => {
.command(open)
.command(migrateApp)
.command(cloneApp)
.command(installDeps)
.demandCommand(1, '');

return yargs;
Expand Down
168 changes: 168 additions & 0 deletions packages/cli/commands/project/__tests__/installDeps.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
jest.mock('../../../lib/projects');
jest.mock('@hubspot/local-dev-lib/logger');
jest.mock('../../../lib/dependencyManagement');
jest.mock('../../../lib/prompts/promptUtils');
jest.mock('../../../lib/usageTracking');
jest.mock('../../../lib/commonOpts');

const { getProjectConfig } = require('../../../lib/projects');
const { EXIT_CODES } = require('../../../lib/enums/exitCodes');
const { logger } = require('@hubspot/local-dev-lib/logger');
const { trackCommandUsage } = require('../../../lib/usageTracking');
const { getAccountId } = require('../../../lib/commonOpts');
const {
installPackages,
getProjectPackageJsonLocations,
} = require('../../../lib/dependencyManagement');
const { promptUser } = require('../../../lib/prompts/promptUtils');
const {
command,
describe: installDepsDescribe,
builder,
handler,
} = require('../installDeps');
const path = require('path');

describe('commands/project/installDeps', () => {
describe('command', () => {
it('should have the correct command string', () => {
expect(command).toEqual('install-deps [packages..]');
});
});

describe('describe', () => {
it('should have the correct description', () => {
expect(installDepsDescribe).toEqual(null);
});
});

describe('builder', () => {
let yargs;
beforeEach(() => {
yargs = {
example: jest.fn().mockImplementation(() => yargs),
};
});

it('should add correct examples', () => {
builder(yargs);
expect(yargs.example).toHaveBeenCalledTimes(1);
expect(yargs.example).toHaveBeenCalledWith([
['$0 project install-deps', 'Install the dependencies for the project'],
[
'$0 project install-deps dependency1 dependency2',
'Install the dependencies to one or more project subcomponents',
],
]);
});
});

describe('handler', () => {
let processExitSpy;

beforeEach(() => {
processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
});

it('should track the command usage', async () => {
const accountId = 999999;
getAccountId.mockReturnValue(accountId);
await handler({});

expect(getAccountId).toHaveBeenCalledTimes(1);
expect(trackCommandUsage).toHaveBeenCalledTimes(1);
expect(trackCommandUsage).toHaveBeenCalledWith(
'project-install-deps',
null,
accountId
);
});

it('should handle exceptions', async () => {
const error = new Error('Something went super wrong');

getProjectConfig.mockImplementationOnce(() => {
throw error;
});

await handler({});

expect(logger.debug).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith(error);

expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(error.message);

expect(processExitSpy).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(EXIT_CODES.ERROR);
});

it('should log an error and exit when the project config is not defined', async () => {
getProjectConfig.mockResolvedValueOnce(null);
await handler({});

expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'No project detected. Run this command from a project directory.'
);
expect(processExitSpy).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(EXIT_CODES.ERROR);
});

it('should log an error and exit when the project config has no projectDir', async () => {
getProjectConfig.mockResolvedValueOnce({ projectDir: null });
await handler({});

expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'No project detected. Run this command from a project directory.'
);
expect(processExitSpy).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(EXIT_CODES.ERROR);
});

it('should prompt for input when packages is defined', async () => {
const projectDir = 'src';
getProjectConfig.mockResolvedValue({ projectDir });
const packageJsonLocation = path.join(projectDir, 'directory1');
promptUser.mockResolvedValueOnce(packageJsonLocation);
getProjectPackageJsonLocations.mockResolvedValue([packageJsonLocation]);
await handler({ packages: ['@hubspot/local-dev-lib'] });
expect(getProjectPackageJsonLocations).toHaveBeenCalledTimes(1);
expect(promptUser).toHaveBeenCalledTimes(1);
expect(promptUser).toHaveBeenCalledWith([
{
name: 'selectedInstallLocations',
type: 'checkbox',
when: expect.any(Function),
choices: [
{
name: 'directory1',
value: packageJsonLocation,
},
],
message: 'Choose the project components to install the dependencies:',
validate: expect.any(Function),
},
]);
});

it('should call installPackages correctly', async () => {
const projectDir = 'src';
const packageJsonLocation = path.join(projectDir, 'directory1');
const installLocations = [packageJsonLocation];
const packages = ['@hubspot/local-dev-lib'];

getProjectConfig.mockResolvedValue({ projectDir });
promptUser.mockResolvedValueOnce(packageJsonLocation);
getProjectPackageJsonLocations.mockResolvedValue(installLocations);
await handler({ packages });

expect(installPackages).toHaveBeenCalledTimes(1);
expect(installPackages).toHaveBeenCalledWith({
packages,
installLocations,
});
});
});
});
78 changes: 78 additions & 0 deletions packages/cli/commands/project/installDeps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const {
installPackages,
getProjectPackageJsonLocations,
} = require('../../lib/dependencyManagement');
const { logger } = require('@hubspot/local-dev-lib/logger');
const { EXIT_CODES } = require('../../lib/enums/exitCodes');
const { getProjectConfig } = require('../../lib/projects');
const { promptUser } = require('../../lib/prompts/promptUtils');
const path = require('path');
const { i18n } = require('../../lib/lang');
const { trackCommandUsage } = require('../../lib/usageTracking');
const { getAccountId } = require('../../lib/commonOpts');

const i18nKey = `commands.project.subcommands.installDeps`;

exports.command = 'install-deps [packages..]';
// Intentionally making this null to hide command
exports.describe = null;
// exports.describe = uiBetaTag(i18n(`${i18nKey}.help.describe`), false);

exports.handler = async ({ packages }) => {
try {
const accountId = getAccountId();
trackCommandUsage('project-install-deps', null, accountId);

const projectConfig = await getProjectConfig();
if (!projectConfig || !projectConfig.projectDir) {
logger.error(i18n(`${i18nKey}.noProjectConfig`));
return process.exit(EXIT_CODES.ERROR);
}

const { projectDir } = projectConfig;

let installLocations = await getProjectPackageJsonLocations();
if (packages) {
const { selectedInstallLocations } = await promptUser([
{
name: 'selectedInstallLocations',
type: 'checkbox',
when: () => packages && packages.length > 0,
message: i18n(`${i18nKey}.installLocationPrompt`),
choices: installLocations.map(dir => ({
name: path.relative(projectDir, dir),
value: dir,
})),
validate: choices => {
if (choices === undefined || choices.length === 0) {
return i18n(`${i18nKey}.installLocationPromptRequired`);
}
return true;
},
},
]);
if (selectedInstallLocations) {
installLocations = selectedInstallLocations;
}
}

await installPackages({
packages,
installLocations,
});
} catch (e) {
logger.debug(e);
logger.error(e.message);
return process.exit(EXIT_CODES.ERROR);
}
};

exports.builder = yargs => {
yargs.example([
['$0 project install-deps', i18n(`${i18nKey}.help.installAppDepsExample`)],
[
'$0 project install-deps dependency1 dependency2',
i18n(`${i18nKey}.help.addDepToSubComponentExample`),
],
]);
};
14 changes: 14 additions & 0 deletions packages/cli/lang/en.lyaml
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,20 @@ en:
describe: "Open Github issues in your browser to report a bug."
general:
describe: "Open Github issues in your browser to give feedback."
installDeps:
help:
describe: "Install the dependencies for your project, or add a dependency to a subcomponent of a project"
installAppDepsExample: "Install the dependencies for the project"
addDepToSubComponentExample: "Install the dependencies to one or more project subcomponents"
installLocationPrompt: "Choose the project components to install the dependencies:"
installLocationPromptRequired: "You must choose at least one subcomponent"
installingDependencies: "Installing dependencies in {{directory}}"
installationSuccessful: "Installed dependencies in {{directory}}"
addingDependenciesToLocation: "Installing {{dependencies}} in {{directory}}"
installingDependenciesFailed: "Installing dependencies for {{directory}} failed"
noProjectConfig: "No project detected. Run this command from a project directory."
noPackageJsonInProject: "No dependencies to install. The project {{ projectName }} folder might be missing component or subcomponent files. {{ link }}"
packageManagerNotInstalled: "This command depends on {{ packageManager }}, install {{#bold}}{{ link }}{{/bold}}"
remove:
describe: "Delete a file or folder from HubSpot."
deleted: "Deleted \"{{ path }}\" from account {{ accountId }}"
Expand Down
Loading

0 comments on commit c662b17

Please sign in to comment.