@hubspot/cli 7.8.0-experimental.0 → 7.8.1-experimental.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/bin/cli.js +0 -2
  2. package/commands/getStarted.d.ts +1 -1
  3. package/commands/getStarted.js +64 -16
  4. package/commands/mcp/setup.js +8 -0
  5. package/commands/project/dev/unifiedFlow.js +1 -1
  6. package/commands/project/migrate.js +30 -21
  7. package/lang/en.d.ts +4 -1
  8. package/lang/en.js +5 -1
  9. package/lib/__tests__/hasFeature.test.js +145 -7
  10. package/lib/app/__tests__/migrate.test.js +14 -51
  11. package/lib/app/migrate.d.ts +2 -8
  12. package/lib/app/migrate.js +5 -80
  13. package/lib/constants.d.ts +3 -0
  14. package/lib/constants.js +3 -0
  15. package/lib/dependencyManagement.d.ts +0 -5
  16. package/lib/dependencyManagement.js +0 -9
  17. package/lib/hasFeature.js +6 -0
  18. package/lib/links.d.ts +1 -0
  19. package/lib/links.js +10 -3
  20. package/lib/mcp/setup.js +1 -1
  21. package/lib/projects/create/v3.js +3 -2
  22. package/lib/projects/localDev/helpers/project.d.ts +2 -2
  23. package/lib/projects/localDev/helpers/project.js +5 -6
  24. package/lib/theme/__tests__/migrate.test.d.ts +1 -0
  25. package/lib/theme/__tests__/migrate.test.js +233 -0
  26. package/lib/theme/migrate.d.ts +13 -0
  27. package/lib/theme/migrate.js +90 -0
  28. package/lib/ui/SpinniesManager.js +105 -8
  29. package/lib/usageTracking.js +2 -2
  30. package/mcp-server/tools/cms/HsCreateFunctionTool.js +1 -1
  31. package/mcp-server/tools/cms/HsCreateModuleTool.js +1 -1
  32. package/mcp-server/tools/cms/HsCreateTemplateTool.js +1 -1
  33. package/mcp-server/tools/cms/HsFunctionLogsTool.js +2 -2
  34. package/mcp-server/tools/cms/HsListFunctionsTool.js +1 -1
  35. package/mcp-server/tools/cms/HsListTool.js +1 -1
  36. package/mcp-server/tools/cms/__tests__/HsCreateFunctionTool.test.js +1 -1
  37. package/mcp-server/tools/cms/__tests__/HsCreateModuleTool.test.js +1 -1
  38. package/mcp-server/tools/cms/__tests__/HsCreateTemplateTool.test.js +1 -1
  39. package/mcp-server/tools/cms/__tests__/HsFunctionLogsTool.test.js +2 -2
  40. package/mcp-server/tools/cms/__tests__/HsListFunctionsTool.test.js +1 -1
  41. package/mcp-server/tools/cms/__tests__/HsListTool.test.js +1 -1
  42. package/mcp-server/tools/project/AddFeatureToProjectTool.d.ts +3 -3
  43. package/mcp-server/tools/project/AddFeatureToProjectTool.js +3 -3
  44. package/mcp-server/tools/project/CreateProjectTool.d.ts +3 -3
  45. package/mcp-server/tools/project/CreateProjectTool.js +5 -5
  46. package/mcp-server/tools/project/DeployProjectTool.js +1 -1
  47. package/mcp-server/tools/project/DocFetchTool.js +2 -2
  48. package/mcp-server/tools/project/DocsSearchTool.d.ts +4 -1
  49. package/mcp-server/tools/project/DocsSearchTool.js +7 -7
  50. package/mcp-server/tools/project/GetConfigValuesTool.d.ts +4 -1
  51. package/mcp-server/tools/project/GetConfigValuesTool.js +11 -5
  52. package/mcp-server/tools/project/GuidedWalkthroughTool.js +1 -1
  53. package/mcp-server/tools/project/UploadProjectTools.js +2 -2
  54. package/mcp-server/tools/project/ValidateProjectTool.js +1 -1
  55. package/mcp-server/tools/project/__tests__/AddFeatureToProjectTool.test.js +1 -1
  56. package/mcp-server/tools/project/__tests__/CreateProjectTool.test.js +1 -1
  57. package/mcp-server/tools/project/__tests__/DeployProjectTool.test.js +1 -1
  58. package/mcp-server/tools/project/__tests__/DocFetchTool.test.js +2 -2
  59. package/mcp-server/tools/project/__tests__/DocsSearchTool.test.js +14 -12
  60. package/mcp-server/tools/project/__tests__/GetConfigValuesTool.test.js +9 -8
  61. package/mcp-server/tools/project/__tests__/GuidedWalkthroughTool.test.js +1 -1
  62. package/mcp-server/tools/project/__tests__/UploadProjectTools.test.js +1 -1
  63. package/mcp-server/tools/project/__tests__/ValidateProjectTool.test.js +1 -1
  64. package/mcp-server/tools/project/constants.d.ts +1 -1
  65. package/mcp-server/tools/project/constants.js +9 -3
  66. package/mcp-server/utils/__tests__/cliConfig.test.d.ts +1 -0
  67. package/mcp-server/utils/__tests__/cliConfig.test.js +110 -0
  68. package/mcp-server/utils/cliConfig.d.ts +1 -0
  69. package/mcp-server/utils/cliConfig.js +12 -0
  70. package/package.json +2 -7
  71. package/ui/components/HorizontalSelectPrompt.js +1 -1
  72. package/commands/getStartedV2.d.ts +0 -9
  73. package/commands/getStartedV2.js +0 -39
  74. package/ui/components/Ascii.d.ts +0 -10
  75. package/ui/components/Ascii.js +0 -11
  76. package/ui/views/GetStarted.d.ts +0 -7
  77. package/ui/views/GetStarted.js +0 -157
package/bin/cli.js CHANGED
@@ -42,7 +42,6 @@ import appCommand from '../commands/app.js';
42
42
  import testAccountCommands from '../commands/testAccount.js';
43
43
  import getStartedCommand from '../commands/getStarted.js';
44
44
  import mcpCommand from '../commands/mcp.js';
45
- import getStartedV2Command from '../commands/getStartedV2.js';
46
45
  function getTerminalWidth() {
47
46
  const width = yargs().terminalWidth();
48
47
  if (width >= 100)
@@ -93,7 +92,6 @@ const argv = yargs(process.argv.slice(2))
93
92
  type: 'boolean',
94
93
  })
95
94
  .check(performChecks)
96
- .command(getStartedV2Command)
97
95
  .command(authCommand)
98
96
  .command(initCommand)
99
97
  .command(logsCommand)
@@ -1,7 +1,7 @@
1
1
  import { AccountArgs, YargsCommandModule, CommonArgs, ConfigArgs, EnvironmentArgs } from '../types/Yargs.js';
2
2
  export declare const command = "get-started";
3
3
  export declare const describe: undefined;
4
- export type GetStartedArgs = CommonArgs & ConfigArgs & AccountArgs & EnvironmentArgs & {
4
+ type GetStartedArgs = CommonArgs & ConfigArgs & AccountArgs & EnvironmentArgs & {
5
5
  name?: string;
6
6
  dest?: string;
7
7
  };
@@ -4,6 +4,7 @@ import open from 'open';
4
4
  import { getCwd } from '@hubspot/local-dev-lib/path';
5
5
  import { cloneGithubRepo } from '@hubspot/local-dev-lib/github';
6
6
  import { commands } from '../lang/en.js';
7
+ import { trackCommandMetadataUsage, trackCommandUsage, } from '../lib/usageTracking.js';
7
8
  import { EXIT_CODES } from '../lib/enums/exitCodes.js';
8
9
  import { makeYargsBuilder } from '../lib/yargsUtils.js';
9
10
  import { promptUser } from '../lib/prompts/promptUtils.js';
@@ -27,9 +28,8 @@ export const describe = undefined;
27
28
  async function handler(args) {
28
29
  const { derivedAccountId } = args;
29
30
  const env = getEnv(derivedAccountId) === 'qa' ? ENVIRONMENTS.QA : ENVIRONMENTS.PROD;
31
+ await trackCommandUsage('get-started', {}, derivedAccountId);
30
32
  const accountName = uiAccountDescription(derivedAccountId);
31
- // TODO: Put this in constants.ts once we have a defined place for the template before INBOUND
32
- const templateSource = 'robrown-hubspot/hubspot-project-components-ua-app-objects-beta';
33
33
  uiInfoSection(commands.getStarted.startTitle, () => {
34
34
  uiLogger.log(commands.getStarted.startDescription);
35
35
  uiLogger.log(commands.getStarted.guideOverview(accountName));
@@ -52,6 +52,8 @@ async function handler(args) {
52
52
  default: GET_STARTED_OPTIONS.APP,
53
53
  },
54
54
  ]);
55
+ // Track user's initial choice
56
+ await trackCommandMetadataUsage('get-started', { step: 'select-option', type: selectedOption }, derivedAccountId);
55
57
  if (selectedOption === GET_STARTED_OPTIONS.CMS) {
56
58
  uiLogger.log(' ');
57
59
  uiLogger.log(commands.getStarted.designManager);
@@ -64,6 +66,11 @@ async function handler(args) {
64
66
  message: commands.getStarted.openDesignManagerPrompt,
65
67
  },
66
68
  ]);
69
+ // Track Design Manager browser action
70
+ await trackCommandMetadataUsage('get-started', {
71
+ step: 'open-design-manager',
72
+ type: shouldOpen ? 'opened' : 'declined',
73
+ }, derivedAccountId);
67
74
  if (shouldOpen) {
68
75
  uiLogger.log('');
69
76
  openLink(derivedAccountId, 'design-manager');
@@ -74,36 +81,37 @@ async function handler(args) {
74
81
  else {
75
82
  uiLogger.log(' ');
76
83
  uiLogger.log(commands.getStarted.logs.appSelected);
77
- // 1. Fetch project templates
78
- let latestRepoReleaseTag;
79
84
  const { dest, name } = await projectNameAndDestPrompt(args);
80
- // Specific template for get-started command
81
- const projectTemplate = {
82
- name: 'private-app-get-started-template',
83
- label: 'CRM getting started project with private apps',
84
- path: 'projects/private-app-get-started-template',
85
- };
86
- // 3. Create the project files
87
85
  const projectDest = path.resolve(getCwd(), dest);
88
86
  const { projectConfig: existingProjectConfig, projectDir: existingProjectDir, } = await getProjectConfig(projectDest);
89
87
  if (existingProjectConfig &&
90
88
  existingProjectDir &&
91
89
  projectDest.startsWith(existingProjectDir)) {
90
+ // Track nested project error
91
+ await trackCommandMetadataUsage('get-started', {
92
+ successful: false,
93
+ step: 'project-creation',
94
+ }, derivedAccountId);
92
95
  uiLogger.log(' ');
93
96
  uiLogger.error(commands.project.create.errors.cannotNestProjects(existingProjectDir));
94
97
  process.exit(EXIT_CODES.ERROR);
95
98
  }
96
- const repo = templateSource || HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH;
97
99
  // 4. Clone the project template from GitHub
98
- // This is temporary until we have the UA template in the main repo
99
100
  try {
100
- await cloneGithubRepo(repo, projectDest, {
101
- sourceDir: projectTemplate.path,
102
- tag: latestRepoReleaseTag,
101
+ await cloneGithubRepo(HUBSPOT_PROJECT_COMPONENTS_GITHUB_PATH, projectDest, {
102
+ sourceDir: '2025.2/private-app-get-started-template',
103
103
  hideLogs: true,
104
104
  });
105
+ await trackCommandMetadataUsage('get-started', {
106
+ successful: true,
107
+ step: 'github-clone',
108
+ }, derivedAccountId);
105
109
  }
106
110
  catch (err) {
111
+ await trackCommandMetadataUsage('get-started', {
112
+ successful: false,
113
+ step: 'github-clone',
114
+ }, derivedAccountId);
107
115
  debugError(err);
108
116
  uiLogger.log(' ');
109
117
  uiLogger.error(commands.project.create.errors.failedToDownloadProject);
@@ -122,6 +130,11 @@ async function handler(args) {
122
130
  uiLogger.log(' ');
123
131
  uiLogger.log(commands.getStarted.prompts.projectCreated.description);
124
132
  uiLogger.log(' ');
133
+ // Track successful project creation
134
+ await trackCommandMetadataUsage('get-started', {
135
+ successful: true,
136
+ step: 'project-creation',
137
+ }, derivedAccountId);
125
138
  // 5. Install dependencies
126
139
  const installLocations = await getProjectPackageJsonLocations(projectDest);
127
140
  try {
@@ -147,11 +160,21 @@ async function handler(args) {
147
160
  default: true,
148
161
  },
149
162
  ]);
163
+ // Track upload decision
164
+ await trackCommandMetadataUsage('get-started', {
165
+ step: 'upload-decision',
166
+ type: shouldUpload ? 'upload' : 'skip',
167
+ }, derivedAccountId);
150
168
  if (shouldUpload) {
151
169
  try {
152
170
  // Get the project config for the newly created project
153
171
  const { projectConfig: newProjectConfig, projectDir: newProjectDir } = await getProjectConfig(projectDest);
154
172
  if (!newProjectConfig || !newProjectDir) {
173
+ // Track config file not found error
174
+ await trackCommandMetadataUsage('get-started', {
175
+ successful: false,
176
+ step: 'config-file-not-found',
177
+ }, derivedAccountId);
155
178
  uiLogger.log(' ');
156
179
  uiLogger.error(commands.getStarted.errors.configFileNotFound);
157
180
  process.exit(EXIT_CODES.ERROR);
@@ -172,11 +195,21 @@ async function handler(args) {
172
195
  skipValidation: false,
173
196
  });
174
197
  if (uploadError) {
198
+ // Track upload failure
199
+ await trackCommandMetadataUsage('get-started', {
200
+ successful: false,
201
+ step: 'upload',
202
+ }, derivedAccountId);
175
203
  uiLogger.log(' ');
176
204
  uiLogger.error(commands.getStarted.errors.uploadFailed);
177
205
  debugError(uploadError);
178
206
  }
179
207
  else if (result) {
208
+ // Track successful upload completion
209
+ await trackCommandMetadataUsage('get-started', {
210
+ successful: true,
211
+ step: 'upload',
212
+ }, derivedAccountId);
180
213
  uiLogger.log(' ');
181
214
  uiLogger.success(commands.getStarted.logs.uploadSuccess);
182
215
  const { data: { results }, } = await fetchPublicAppsForPortal(derivedAccountId);
@@ -192,6 +225,11 @@ async function handler(args) {
192
225
  message: commands.getStarted.openInstallUrl,
193
226
  },
194
227
  ]);
228
+ // Track Developer Overview browser action
229
+ await trackCommandMetadataUsage('get-started', {
230
+ step: 'open-distribution-page',
231
+ type: shouldOpenOverview ? 'opened' : 'declined',
232
+ }, derivedAccountId);
195
233
  if (shouldOpenOverview) {
196
234
  open(getStaticAuthAppInstallUrl({
197
235
  targetAccountId: derivedAccountId,
@@ -207,6 +245,11 @@ async function handler(args) {
207
245
  }
208
246
  }
209
247
  catch (err) {
248
+ // Track upload exception
249
+ await trackCommandMetadataUsage('get-started', {
250
+ successful: false,
251
+ step: 'upload',
252
+ }, derivedAccountId);
210
253
  uiLogger.log(' ');
211
254
  uiLogger.error(commands.getStarted.errors.uploadFailed);
212
255
  debugError(err);
@@ -214,6 +257,11 @@ async function handler(args) {
214
257
  }
215
258
  }
216
259
  }
260
+ // Track successful completion of get-started command
261
+ await trackCommandMetadataUsage('get-started', {
262
+ successful: true,
263
+ step: 'command-completed',
264
+ }, derivedAccountId);
217
265
  process.exit(EXIT_CODES.SUCCESS);
218
266
  }
219
267
  function getStartedBuilder(yargs) {
@@ -4,9 +4,17 @@ import { commands } from '../../lang/en.js';
4
4
  import { uiLogger } from '../../lib/ui/logger.js';
5
5
  import { addMcpServerToConfig, supportedTools } from '../../lib/mcp/setup.js';
6
6
  import { trackCommandUsage } from '../../lib/usageTracking.js';
7
+ import { hasFeature } from '../../lib/hasFeature.js';
8
+ import { FEATURES } from '../../lib/constants.js';
7
9
  const command = ['setup', 'update'];
8
10
  const describe = undefined; // Leave hidden for now
9
11
  async function handler(args) {
12
+ const { derivedAccountId } = args;
13
+ const hasMcpAccess = await hasFeature(derivedAccountId, FEATURES.MCP_ACCESS);
14
+ if (!hasMcpAccess) {
15
+ uiLogger.error(commands.mcp.setup.errors.needsMcpAccess(derivedAccountId));
16
+ process.exit(EXIT_CODES.ERROR);
17
+ }
10
18
  try {
11
19
  await import('@modelcontextprotocol/sdk/server/mcp.js');
12
20
  }
@@ -109,7 +109,7 @@ export async function unifiedProjectDevFlow({ args, targetProjectAccountId, prov
109
109
  let project = uploadedProject;
110
110
  SpinniesManager.init();
111
111
  if (projectExists && project) {
112
- await compareLocalProjectToDeployed(projectConfig, targetProjectAccountId, project.deployedBuild?.buildId, projectNodes);
112
+ await compareLocalProjectToDeployed(projectConfig, targetProjectAccountId, project.deployedBuild?.buildId, projectNodes, args.profile);
113
113
  }
114
114
  else {
115
115
  project = await createNewProjectForLocalDev(projectConfig, targetProjectAccountId, false, false);
@@ -8,8 +8,9 @@ import { uiCommandReference } from '../../lib/ui/index.js';
8
8
  import { commands, lib } from '../../lang/en.js';
9
9
  import { uiLogger } from '../../lib/ui/logger.js';
10
10
  import { logInBox } from '../../lib/ui/boxen.js';
11
- import { renderInline } from '../../ui/index.js';
12
- import { getWarningBox } from '../../ui/components/StatusMessageBoxes.js';
11
+ import { getHasMigratableThemes, migrateThemes2025_2, } from '../../lib/theme/migrate.js';
12
+ import { hasFeature } from '../../lib/hasFeature.js';
13
+ import { FEATURES } from '../../lib/constants.js';
13
14
  const { v2025_2 } = PLATFORM_VERSIONS;
14
15
  const command = 'migrate';
15
16
  const describe = undefined; // commands.project.migrate.describe
@@ -21,28 +22,36 @@ async function handler(args) {
21
22
  return process.exit(EXIT_CODES.ERROR);
22
23
  }
23
24
  if (projectConfig?.projectConfig) {
24
- if (!process.env.ENABLE_INK) {
25
- await logInBox({
26
- contents: lib.migrate.projectMigrationWarning,
27
- options: { title: lib.migrate.projectMigrationWarningTitle },
28
- });
29
- }
30
- else {
31
- await renderInline(getWarningBox({
32
- title: lib.migrate.projectMigrationWarningTitle,
33
- message: lib.migrate.projectMigrationWarning,
34
- }));
35
- }
25
+ await logInBox({
26
+ contents: lib.migrate.projectMigrationWarning,
27
+ options: { title: lib.migrate.projectMigrationWarningTitle },
28
+ });
36
29
  }
37
30
  const { derivedAccountId } = args;
38
31
  try {
39
- await migrateApp2025_2(derivedAccountId, {
40
- ...args,
41
- name: projectConfig?.projectConfig?.name,
42
- platformVersion: unstable
43
- ? PLATFORM_VERSIONS.unstable
44
- : platformVersion,
45
- }, projectConfig);
32
+ const { hasMigratableThemes, migratableThemesCount } = await getHasMigratableThemes(projectConfig);
33
+ if (hasMigratableThemes) {
34
+ const hasThemeMigrationAccess = await hasFeature(derivedAccountId, FEATURES.THEME_MIGRATION_2025_2);
35
+ if (!hasThemeMigrationAccess) {
36
+ uiLogger.error(commands.project.migrate.errors.noThemeMigrationAccess(derivedAccountId));
37
+ return process.exit(EXIT_CODES.ERROR);
38
+ }
39
+ await migrateThemes2025_2(derivedAccountId, {
40
+ ...args,
41
+ platformVersion: unstable
42
+ ? PLATFORM_VERSIONS.unstable
43
+ : platformVersion,
44
+ }, migratableThemesCount, projectConfig);
45
+ }
46
+ else {
47
+ await migrateApp2025_2(derivedAccountId, {
48
+ ...args,
49
+ name: projectConfig?.projectConfig?.name,
50
+ platformVersion: unstable
51
+ ? PLATFORM_VERSIONS.unstable
52
+ : platformVersion,
53
+ }, projectConfig);
54
+ }
46
55
  }
47
56
  catch (error) {
48
57
  logError(error);
package/lang/en.d.ts CHANGED
@@ -872,6 +872,7 @@ Global configuration replaces hubspot.config.yml, and you will be prompted to mi
872
872
  };
873
873
  readonly success: (derivedTargets: string[]) => string;
874
874
  readonly errors: {
875
+ readonly needsMcpAccess: (accountId?: number) => string;
875
876
  readonly needsNode20: "This feature requires node >=20";
876
877
  readonly errorParsingJsonFIle: (filename: string, errorMessage: string) => string;
877
878
  };
@@ -1136,6 +1137,7 @@ ${string}`;
1136
1137
  readonly describe: "Migrate an existing project to the new version of the projects framework.";
1137
1138
  readonly errors: {
1138
1139
  readonly noProjectConfig: (command: string) => string;
1140
+ readonly noThemeMigrationAccess: (accountId?: number) => string;
1139
1141
  };
1140
1142
  readonly examples: {
1141
1143
  readonly default: "Migrate an existing project to the new version of the projects framework.";
@@ -2601,7 +2603,7 @@ export declare const lib: {
2601
2603
  readonly checking: "Checking if your deployed build is up to date...";
2602
2604
  readonly upToDate: "Deployed build is up to date.";
2603
2605
  readonly notUpToDate: "Your project contains undeployed local changes.";
2604
- readonly notUpToDateExplanation: `Run ${string} to upload these changes to HubSpot, then re-run ${string} to continue local development.`;
2606
+ readonly notUpToDateExplanation: (profile?: string) => string;
2605
2607
  };
2606
2608
  readonly createNewProjectForLocalDev: {
2607
2609
  readonly projectMustExistExplanation: (projectName: string, accountId: number) => string;
@@ -3388,6 +3390,7 @@ Run ${string} to upgrade to version ${string}`;
3388
3390
  readonly sourceContentsMoved: (newLocation: string) => string;
3389
3391
  readonly projectMigrationWarningTitle: "Important: Migrating to platformVersion 2025.2 is irreversible";
3390
3392
  readonly projectMigrationWarning: string;
3393
+ readonly exitWithoutMigrating: "Exiting without migrating";
3391
3394
  readonly success: {
3392
3395
  readonly downloadedProject: (projectName: string, projectDest: string) => string;
3393
3396
  readonly themesMigrationSuccess: (platformVersion: string) => string;
package/lang/en.js CHANGED
@@ -5,6 +5,7 @@ import { PERSONAL_ACCESS_KEY_AUTH_METHOD } from '@hubspot/local-dev-lib/constant
5
5
  import { ARCHIVED_HUBSPOT_CONFIG_YAML_FILE_NAME, GLOBAL_CONFIG_PATH, DEFAULT_HUBSPOT_CONFIG_YAML_FILE_NAME, } from '@hubspot/local-dev-lib/constants/config';
6
6
  import { uiAccountDescription, uiBetaTag, uiCommandReference, uiLink, UI_COLORS, } from '../lib/ui/index.js';
7
7
  import { getProjectDetailUrl, getProjectSettingsUrl, getLocalDevUiUrl, getAppAllowlistUrl, } from '../lib/projects/urls.js';
8
+ import { getProductUpdatesUrl } from '../lib/links.js';
8
9
  import { APP_DISTRIBUTION_TYPES, APP_AUTH_TYPES, PROJECT_CONFIG_FILE, PROJECT_WITH_APP, } from '../lib/constants.js';
9
10
  import { getAccountIdentifier } from '@hubspot/local-dev-lib/config/getAccountIdentifier';
10
11
  export const commands = {
@@ -875,6 +876,7 @@ export const commands = {
875
876
  },
876
877
  success: (derivedTargets) => `You can now use the HubSpot CLI MCP Server in ${derivedTargets.join(', ')}. ${chalk.bold('You may need to restart these tools to apply the changes')}.`,
877
878
  errors: {
879
+ needsMcpAccess: (accountId) => `You must opt in to the developer MCP beta to use this feature on ${uiAccountDescription(accountId)}. Try again with a different account or ${uiLink('join the beta now', getProductUpdatesUrl('239890', accountId))}`,
878
880
  needsNode20: `This feature requires node >=20`,
879
881
  errorParsingJsonFIle: (filename, errorMessage) => `Unable to update ${chalk.bold(filename)} due to invalid JSON: ${errorMessage}`,
880
882
  },
@@ -1135,6 +1137,7 @@ export const commands = {
1135
1137
  describe: 'Migrate an existing project to the new version of the projects framework.',
1136
1138
  errors: {
1137
1139
  noProjectConfig: (command) => `No project detected. Please run this command again from a project directory. If you are trying to migrate an app, run ${command}`,
1140
+ noThemeMigrationAccess: (accountId) => `This project contains a CMS theme. You must opt in to theme migration beta to continue updating it on ${uiAccountDescription(accountId)}. Try again with a different account or ${uiLink('join the beta now', getProductUpdatesUrl('253920', accountId))}`,
1138
1141
  },
1139
1142
  examples: {
1140
1143
  default: 'Migrate an existing project to the new version of the projects framework.',
@@ -2598,7 +2601,7 @@ export const lib = {
2598
2601
  checking: 'Checking if your deployed build is up to date...',
2599
2602
  upToDate: 'Deployed build is up to date.',
2600
2603
  notUpToDate: `Your project contains undeployed local changes.`,
2601
- notUpToDateExplanation: `Run ${uiCommandReference('hs project upload')} to upload these changes to HubSpot, then re-run ${uiCommandReference('hs project dev')} to continue local development.`,
2604
+ notUpToDateExplanation: (profile) => `Run ${uiCommandReference(`hs project upload ${profile ? `--profile ${profile}` : ''}`)} to upload these changes to HubSpot, then re-run ${uiCommandReference(`hs project dev ${profile ? `--profile ${profile}` : ''}`)} to continue local development.`,
2602
2605
  },
2603
2606
  createNewProjectForLocalDev: {
2604
2607
  projectMustExistExplanation: (projectName, accountId) => `The project ${projectName} does not exist in the target account ${uiAccountDescription(accountId)}. This command requires the project to exist in the target account.`,
@@ -3382,6 +3385,7 @@ export const lib = {
3382
3385
  sourceContentsMoved: (newLocation) => `The contents of your old source directory have been moved to ${newLocation}, move any required files to the new source directory.`,
3383
3386
  projectMigrationWarningTitle: 'Important: Migrating to platformVersion 2025.2 is irreversible',
3384
3387
  projectMigrationWarning: uiBetaTag(`Running the ${uiCommandReference('hs project migrate')} command will permanently upgrade your project to platformVersion 2025.2. This action cannot be undone. To ensure you have access to your original files, they will be copied to a new directory (archive) for safekeeping.\n\nThis command will guide you through the process, prompting you to enter the required fields and will download the new project source code into your project source directory.`, false),
3388
+ exitWithoutMigrating: 'Exiting without migrating',
3385
3389
  success: {
3386
3390
  downloadedProject: (projectName, projectDest) => `Saved ${projectName} to ${projectDest}`,
3387
3391
  themesMigrationSuccess: (platformVersion) => `Successfully migrated project to platformVersion ${chalk.bold(platformVersion)}. Upload your project using ${uiCommandReference('hs project upload')}`,
@@ -1,35 +1,173 @@
1
1
  import { fetchEnabledFeatures } from '@hubspot/local-dev-lib/api/localDevAuth';
2
- import { hasFeature } from '../hasFeature.js';
2
+ import { http } from '@hubspot/local-dev-lib/http';
3
+ import { hasFeature, hasUnfiedAppsAccess } from '../hasFeature.js';
4
+ import { FEATURES } from '../constants.js';
3
5
  vi.mock('@hubspot/local-dev-lib/api/localDevAuth');
6
+ vi.mock('@hubspot/local-dev-lib/http');
4
7
  const mockedFetchEnabledFeatures = fetchEnabledFeatures;
8
+ const mockedHttp = http;
5
9
  describe('lib/hasFeature', () => {
6
10
  describe('hasFeature()', () => {
7
11
  const accountId = 123;
8
- beforeEach(() => {
12
+ afterEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+ it('should return true if the feature is enabled', async () => {
9
16
  mockedFetchEnabledFeatures.mockResolvedValueOnce({
10
17
  data: {
11
18
  enabledFeatures: {
12
19
  'feature-1': true,
13
- 'feature-2': false,
14
- 'feature-3': true,
15
20
  },
16
21
  },
17
22
  });
18
- });
19
- it('should return true if the feature is enabled', async () => {
20
23
  // @ts-expect-error test data
21
24
  const result = await hasFeature(accountId, 'feature-1');
22
25
  expect(result).toBe(true);
23
26
  });
24
- it('should return false if the feature is not enabled', async () => {
27
+ it('should return false if the feature is disabled', async () => {
28
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
29
+ data: {
30
+ enabledFeatures: {
31
+ 'feature-2': false,
32
+ },
33
+ },
34
+ });
25
35
  // @ts-expect-error test data
26
36
  const result = await hasFeature(accountId, 'feature-2');
27
37
  expect(result).toBe(false);
28
38
  });
29
39
  it('should return false if the feature is not present', async () => {
40
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
41
+ data: {
42
+ enabledFeatures: {},
43
+ },
44
+ });
30
45
  // @ts-expect-error test data
31
46
  const result = await hasFeature(accountId, 'feature-4');
32
47
  expect(result).toBe(false);
33
48
  });
49
+ it('should return true for APPS_HOME feature when not present in enabled features (defaults on)', async () => {
50
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
51
+ data: {
52
+ enabledFeatures: {},
53
+ },
54
+ });
55
+ const result = await hasFeature(accountId, FEATURES.APPS_HOME);
56
+ expect(result).toBe(true);
57
+ });
58
+ it('should respect explicit setting for APPS_HOME feature even when it defaults on', async () => {
59
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
60
+ data: {
61
+ enabledFeatures: {
62
+ [FEATURES.APPS_HOME]: false,
63
+ },
64
+ },
65
+ });
66
+ const result = await hasFeature(accountId, FEATURES.APPS_HOME);
67
+ expect(result).toBe(false);
68
+ });
69
+ it('should handle truthy values correctly', async () => {
70
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
71
+ data: {
72
+ enabledFeatures: {
73
+ 'feature-truthy': 'yes',
74
+ },
75
+ },
76
+ });
77
+ // @ts-expect-error test data
78
+ const truthyResult = await hasFeature(accountId, 'feature-truthy');
79
+ expect(truthyResult).toBe(true);
80
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
81
+ data: {
82
+ enabledFeatures: {
83
+ 'feature-number': 1,
84
+ },
85
+ },
86
+ });
87
+ // @ts-expect-error test data
88
+ const numberResult = await hasFeature(accountId, 'feature-number');
89
+ expect(numberResult).toBe(true);
90
+ });
91
+ it('should handle falsy values correctly', async () => {
92
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
93
+ data: {
94
+ enabledFeatures: {
95
+ 'feature-null': null,
96
+ },
97
+ },
98
+ });
99
+ // @ts-expect-error test data
100
+ const nullResult = await hasFeature(accountId, 'feature-null');
101
+ expect(nullResult).toBe(false);
102
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
103
+ data: {
104
+ enabledFeatures: {
105
+ 'feature-zero': 0,
106
+ },
107
+ },
108
+ });
109
+ // @ts-expect-error test data
110
+ const zeroResult = await hasFeature(accountId, 'feature-zero');
111
+ expect(zeroResult).toBe(false);
112
+ mockedFetchEnabledFeatures.mockResolvedValueOnce({
113
+ data: {
114
+ enabledFeatures: {
115
+ 'feature-empty': '',
116
+ },
117
+ },
118
+ });
119
+ // @ts-expect-error test data
120
+ const emptyResult = await hasFeature(accountId, 'feature-empty');
121
+ expect(emptyResult).toBe(false);
122
+ });
123
+ it('should propagate errors from fetchEnabledFeatures', async () => {
124
+ const error = new Error('API error');
125
+ mockedFetchEnabledFeatures.mockRejectedValueOnce(error);
126
+ await expect(hasFeature(accountId, FEATURES.UNIFIED_APPS)).rejects.toThrow('API error');
127
+ });
128
+ });
129
+ describe('hasUnfiedAppsAccess()', () => {
130
+ const accountId = 123;
131
+ afterEach(() => {
132
+ vi.clearAllMocks();
133
+ });
134
+ it('should return true when API returns true', async () => {
135
+ // @ts-expect-error Don't want to mock the full response object
136
+ mockedHttp.get.mockResolvedValueOnce({ data: true });
137
+ const result = await hasUnfiedAppsAccess(accountId);
138
+ expect(result).toBe(true);
139
+ expect(mockedHttp.get).toHaveBeenCalledWith(accountId, {
140
+ url: 'developer-tooling/external/developer-portal/has-unified-dev-platform-access',
141
+ });
142
+ });
143
+ it('should return false when API returns false', async () => {
144
+ // @ts-expect-error Don't want to mock the full response object
145
+ mockedHttp.get.mockResolvedValueOnce({ data: false });
146
+ const result = await hasUnfiedAppsAccess(accountId);
147
+ expect(result).toBe(false);
148
+ });
149
+ it('should handle truthy values correctly', async () => {
150
+ // @ts-expect-error Don't want to mock the full response object
151
+ mockedHttp.get.mockResolvedValueOnce({ data: 'yes' });
152
+ const result = await hasUnfiedAppsAccess(accountId);
153
+ expect(result).toBe(true);
154
+ });
155
+ it('should handle falsy values correctly', async () => {
156
+ // @ts-expect-error Don't want to mock the full response object
157
+ mockedHttp.get.mockResolvedValueOnce({ data: null });
158
+ const result = await hasUnfiedAppsAccess(accountId);
159
+ expect(result).toBe(false);
160
+ });
161
+ it('should handle undefined response data', async () => {
162
+ // @ts-expect-error Don't want to mock the full response object
163
+ mockedHttp.get.mockResolvedValueOnce({ data: undefined });
164
+ const result = await hasUnfiedAppsAccess(accountId);
165
+ expect(result).toBe(false);
166
+ });
167
+ it('should propagate errors from http.get', async () => {
168
+ const error = new Error('Network error');
169
+ mockedHttp.get.mockRejectedValueOnce(error);
170
+ await expect(hasUnfiedAppsAccess(accountId)).rejects.toThrow('Network error');
171
+ });
34
172
  });
35
173
  });