@servicetitan/startup 29.0.0 → 30.0.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 (106) hide show
  1. package/bin/index.js +1 -1
  2. package/dist/cli/commands/convert-eslint-config.d.ts +21 -0
  3. package/dist/cli/commands/convert-eslint-config.d.ts.map +1 -0
  4. package/dist/cli/commands/convert-eslint-config.js +235 -0
  5. package/dist/cli/commands/convert-eslint-config.js.map +1 -0
  6. package/dist/cli/commands/get-command.d.ts.map +1 -1
  7. package/dist/cli/commands/get-command.js +6 -0
  8. package/dist/cli/commands/get-command.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts.map +1 -1
  10. package/dist/cli/commands/init.js +4 -3
  11. package/dist/cli/commands/init.js.map +1 -1
  12. package/dist/cli/commands/lint.d.ts +1 -1
  13. package/dist/cli/commands/lint.js +2 -2
  14. package/dist/cli/commands/run-task.d.ts +13 -0
  15. package/dist/cli/commands/run-task.d.ts.map +1 -0
  16. package/dist/cli/commands/run-task.js +53 -0
  17. package/dist/cli/commands/run-task.js.map +1 -0
  18. package/dist/cli/index.js +13 -4
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/tasks/cli-task.d.ts +16 -0
  21. package/dist/cli/tasks/cli-task.d.ts.map +1 -0
  22. package/dist/cli/tasks/cli-task.js +58 -0
  23. package/dist/cli/tasks/cli-task.js.map +1 -0
  24. package/dist/cli/tasks/swc-compile-package.d.ts +12 -0
  25. package/dist/cli/tasks/swc-compile-package.d.ts.map +1 -0
  26. package/dist/cli/tasks/swc-compile-package.js +66 -0
  27. package/dist/cli/tasks/swc-compile-package.js.map +1 -0
  28. package/dist/cli/tasks/task.d.ts +42 -0
  29. package/dist/cli/tasks/task.d.ts.map +1 -0
  30. package/dist/cli/tasks/task.js +113 -0
  31. package/dist/cli/tasks/task.js.map +1 -0
  32. package/dist/cli/tasks/tsc-compile-package.d.ts +12 -0
  33. package/dist/cli/tasks/tsc-compile-package.d.ts.map +1 -0
  34. package/dist/cli/tasks/tsc-compile-package.js +42 -0
  35. package/dist/cli/tasks/tsc-compile-package.js.map +1 -0
  36. package/dist/cli/tasks/tsc-compile.d.ts +16 -0
  37. package/dist/cli/tasks/tsc-compile.d.ts.map +1 -0
  38. package/dist/cli/tasks/tsc-compile.js +48 -0
  39. package/dist/cli/tasks/tsc-compile.js.map +1 -0
  40. package/dist/cli/utils/bundle.js +1 -1
  41. package/dist/cli/utils/bundle.js.map +1 -1
  42. package/dist/cli/utils/cli-os.js +2 -2
  43. package/dist/cli/utils/cli-os.js.map +1 -1
  44. package/dist/cli/utils/eslint.d.ts.map +1 -1
  45. package/dist/cli/utils/eslint.js +13 -12
  46. package/dist/cli/utils/eslint.js.map +1 -1
  47. package/dist/cli/utils/is-module-installed.js +1 -1
  48. package/dist/cli/utils/is-module-installed.js.map +1 -1
  49. package/dist/cli/utils/tsc.d.ts +3 -2
  50. package/dist/cli/utils/tsc.d.ts.map +1 -1
  51. package/dist/cli/utils/tsc.js +20 -16
  52. package/dist/cli/utils/tsc.js.map +1 -1
  53. package/dist/utils/get-configuration.d.ts +3 -1
  54. package/dist/utils/get-configuration.d.ts.map +1 -1
  55. package/dist/utils/get-configuration.js +2 -0
  56. package/dist/utils/get-configuration.js.map +1 -1
  57. package/dist/utils/get-destination-folders.d.ts.map +1 -1
  58. package/dist/utils/get-destination-folders.js +9 -13
  59. package/dist/utils/get-destination-folders.js.map +1 -1
  60. package/dist/utils/get-folders.js +2 -2
  61. package/dist/utils/get-folders.js.map +1 -1
  62. package/dist/utils/log.d.ts +1 -0
  63. package/dist/utils/log.d.ts.map +1 -1
  64. package/dist/utils/log.js +3 -0
  65. package/dist/utils/log.js.map +1 -1
  66. package/dist/webpack/configs/plugins/ignore-plugin/check-resource.js +1 -1
  67. package/dist/webpack/configs/plugins/ignore-plugin/check-resource.js.map +1 -1
  68. package/package.json +15 -9
  69. package/src/cli/commands/__tests__/convert-eslint-config.test.ts +455 -0
  70. package/src/cli/commands/__tests__/lint.test.ts +2 -2
  71. package/src/cli/commands/convert-eslint-config.ts +289 -0
  72. package/src/cli/commands/get-command.ts +6 -0
  73. package/src/cli/commands/init.ts +4 -3
  74. package/src/cli/commands/lint.ts +3 -3
  75. package/src/cli/commands/run-task.ts +41 -0
  76. package/src/cli/index.ts +16 -4
  77. package/src/cli/tasks/__tests__/cli-task.test.ts +115 -0
  78. package/src/cli/tasks/__tests__/swc-compile.test.ts +192 -0
  79. package/src/cli/tasks/__tests__/task.test.ts +88 -0
  80. package/src/cli/tasks/__tests__/tsc-compile-package.test.ts +72 -0
  81. package/src/cli/tasks/__tests__/tsc-compile.test.ts +156 -0
  82. package/src/cli/tasks/cli-task.ts +64 -0
  83. package/src/cli/tasks/swc-cli.d.ts +3 -0
  84. package/src/cli/tasks/swc-compile-package.ts +70 -0
  85. package/src/cli/tasks/task.ts +112 -0
  86. package/src/cli/tasks/tsc-compile-package.ts +47 -0
  87. package/src/cli/tasks/tsc-compile.ts +64 -0
  88. package/src/cli/utils/__tests__/assets-copy.test.ts +1 -1
  89. package/src/cli/utils/__tests__/bundle.test.ts +1 -1
  90. package/src/cli/utils/__tests__/cli-os.test.ts +2 -2
  91. package/src/cli/utils/__tests__/eslint.test.ts +37 -8
  92. package/src/cli/utils/__tests__/styles-copy.test.ts +1 -1
  93. package/src/cli/utils/__tests__/tsc.test.ts +34 -55
  94. package/src/cli/utils/bundle.ts +1 -1
  95. package/src/cli/utils/cli-os.ts +2 -2
  96. package/src/cli/utils/eslint.ts +16 -13
  97. package/src/cli/utils/is-module-installed.ts +1 -1
  98. package/src/cli/utils/tsc.ts +25 -20
  99. package/src/utils/__tests__/get-destination-folders.test.ts +1 -1
  100. package/src/utils/__tests__/get-folders.test.ts +6 -18
  101. package/src/utils/__tests__/log.test.ts +6 -0
  102. package/src/utils/get-configuration.ts +2 -0
  103. package/src/utils/get-destination-folders.ts +11 -17
  104. package/src/utils/get-folders.ts +2 -2
  105. package/src/utils/log.ts +4 -0
  106. package/src/webpack/configs/plugins/ignore-plugin/check-resource.ts +1 -1
@@ -0,0 +1,289 @@
1
+ import execa from 'execa';
2
+ import fs from 'fs';
3
+ import { globSync } from 'glob';
4
+ import JSON5 from 'json5';
5
+
6
+ import { log, logErrors, toArray } from '../../utils';
7
+ import { Command } from './types';
8
+
9
+ const oldConfigFile = '.eslintrc.json';
10
+ const newConfigFile = 'eslint.config.mjs';
11
+ const eslintIgnoreFile = '.eslintignore';
12
+
13
+ interface Override {
14
+ [key: string]: unknown;
15
+ files?: string[];
16
+ }
17
+
18
+ interface V8Config {
19
+ extends?: string | string[];
20
+ ignorePatterns?: string[];
21
+ overrides?: Override[];
22
+ plugins?: string[];
23
+ parserOptions?: Record<string, unknown>;
24
+ rules?: Record<string, unknown>;
25
+ settings?: Record<string, unknown>;
26
+ }
27
+
28
+ interface Context {
29
+ flatConfigs: string[];
30
+ imports: {
31
+ [key: string]: string | string[];
32
+ 'eslint/config': string[];
33
+ };
34
+ source: V8Config;
35
+ }
36
+
37
+ interface Recipe {
38
+ config: string;
39
+ files?: string[];
40
+ import: { as: string; from?: string };
41
+ }
42
+
43
+ export class ConvertEslintConfig implements Command {
44
+ private static readonly recipes: Record<string, Recipe | null> = {
45
+ '@servicetitan/eslint-config/mono': {
46
+ import: { as: 'mono' },
47
+ config: 'mono',
48
+ },
49
+ '@servicetitan/eslint-config/single': {
50
+ import: { as: 'single' },
51
+ config: 'single',
52
+ },
53
+ 'plugin:chai-friendly/recommended': {
54
+ import: { as: 'pluginChaiFriendly', from: 'eslint-plugin-chai-friendly' },
55
+ config: 'pluginChaiFriendly.configs.recommendedFlat',
56
+ },
57
+ 'plugin:cypress/recommended': {
58
+ import: { as: 'pluginCypress', from: 'eslint-plugin-cypress/flat' },
59
+ config: 'pluginCypress.configs.recommended',
60
+ },
61
+ 'plugin:mocha/recommended': {
62
+ import: { as: 'pluginMocha', from: 'eslint-plugin-mocha' },
63
+ config: 'pluginMocha.configs.flat.recommended',
64
+ },
65
+ 'plugin:storybook/recommended': {
66
+ import: { as: 'storybook', from: 'eslint-plugin-storybook' },
67
+ config: "storybook.configs['flat/recommended']",
68
+ },
69
+ 'plugin:testing-library/react': {
70
+ import: { as: 'pluginTestingLibrary', from: 'eslint-plugin-testing-library' },
71
+ config: "pluginTestingLibrary.configs['flat/react']",
72
+ files: ['**/*.test.*'],
73
+ },
74
+ 'prettier': null,
75
+ };
76
+
77
+ description() {
78
+ return 'convert v8.x eslintrc.json to v9.x flat config';
79
+ }
80
+
81
+ @logErrors
82
+ async execute() {
83
+ if (!fs.existsSync(oldConfigFile)) {
84
+ throw new Error(`${oldConfigFile} not found`);
85
+ }
86
+
87
+ const configFiles = globSync('eslint.config.{js,cjs,mjs}');
88
+ if (configFiles.length > 0) {
89
+ throw new Error(`flat config "${configFiles[0]}" already exists`);
90
+ }
91
+
92
+ log.info(`Processing ${oldConfigFile}`);
93
+
94
+ const json = this.readJson(oldConfigFile);
95
+ fs.writeFileSync(newConfigFile, this.convert(json), 'utf-8');
96
+ await this.prettify(newConfigFile);
97
+
98
+ log.success(`Created ${newConfigFile}`);
99
+ }
100
+
101
+ private convert(source: V8Config) {
102
+ const context: Context = {
103
+ flatConfigs: [],
104
+ imports: { 'eslint/config': ['defineConfig'] },
105
+ source,
106
+ };
107
+
108
+ const handlers: { [K in keyof V8Config]: (context: Context) => void } = {
109
+ extends: this.handleExtends.bind(this),
110
+ overrides: this.handleOverrides.bind(this),
111
+ parserOptions: this.handleParserOptions.bind(this),
112
+ plugins: this.handlePlugins.bind(this),
113
+ rules: this.handleRules.bind(this),
114
+ settings: this.handleSettings.bind(this),
115
+ };
116
+
117
+ // Don't limit handleIgnores to a specific key because it also reads .eslintignore.rc
118
+ this.handleIgnores(context);
119
+
120
+ // Run handlers in key order to preserve ordering of configs
121
+ Object.keys(context.source).forEach((key: keyof V8Config) => {
122
+ handlers[key]?.(context);
123
+ });
124
+
125
+ return this.format(context);
126
+ }
127
+
128
+ private format(context: Context) {
129
+ return `${this.formatImports(context)}\n\n${this.formatConfigs(context)}`;
130
+ }
131
+
132
+ private formatConfigs(context: Context) {
133
+ return `export default defineConfig([
134
+ ${context.flatConfigs.join()}
135
+ ]);`;
136
+ }
137
+
138
+ private formatImports({ imports }: Context) {
139
+ return Object.entries(imports)
140
+ .map(([pkg, itemOrItems]) => {
141
+ if (typeof itemOrItems === 'string') {
142
+ return `import ${itemOrItems} from '${pkg}';`;
143
+ }
144
+ return `import { ${itemOrItems.join()} } from '${pkg}';`;
145
+ })
146
+ .join('\n');
147
+ }
148
+
149
+ private handleExtends({ flatConfigs, imports, source }: Context) {
150
+ const unrecognized: string[] = [];
151
+ const extendsArray = toArray(source.extends);
152
+
153
+ extendsArray.forEach(entry => {
154
+ const recipe = ConvertEslintConfig.recipes[entry];
155
+ if (recipe) {
156
+ imports[recipe.import.from ?? entry] = recipe.import.as;
157
+ if (recipe.files) {
158
+ flatConfigs.push(`{
159
+ files: ${JSON.stringify(recipe.files)},
160
+ ...${recipe.config}
161
+ }`);
162
+ } else {
163
+ flatConfigs.push(recipe.config);
164
+ }
165
+ } else if (recipe !== null) {
166
+ unrecognized.push(entry);
167
+ }
168
+ });
169
+
170
+ /*
171
+ * Use extends workaround for anything we didn't recognized
172
+ * https://eslint.org/blog/2025/03/flat-config-extends-define-config-global-ignores/#bringing-back-extends
173
+ */
174
+
175
+ if (unrecognized.length) {
176
+ flatConfigs.push(JSON.stringify({ extends: unrecognized }));
177
+ }
178
+ }
179
+
180
+ private handleIgnores({ flatConfigs, imports, source: { ignorePatterns = [] } }: Context) {
181
+ const globs = ignorePatterns.filter(glob => !!glob);
182
+
183
+ if (fs.existsSync(eslintIgnoreFile)) {
184
+ const fileGlobs = fs
185
+ .readFileSync(eslintIgnoreFile, 'utf-8')
186
+ .split('\n')
187
+ .map(line => line.trim())
188
+ .filter(line => !!line);
189
+ globs.push(...fileGlobs);
190
+ }
191
+
192
+ if (globs.length === 0) {
193
+ return;
194
+ }
195
+
196
+ const normalized = globs.map(normalizeGlob);
197
+
198
+ imports['eslint/config'].push('globalIgnores');
199
+ flatConfigs.push(`globalIgnores(${JSON.stringify(normalized)})`);
200
+ }
201
+
202
+ private handleOverrides({
203
+ flatConfigs,
204
+ source: { overrides },
205
+ }: Context & { source: { overrides: NonNullable<Context['source']['overrides']> } }) {
206
+ overrides.forEach(override => {
207
+ flatConfigs.push(JSON.stringify(this.handleOverride(override), null, 2));
208
+ });
209
+ }
210
+
211
+ private handleOverride({ parserOptions, ...rest }: Override) {
212
+ rest.files = rest.files?.map(normalizeGlob);
213
+ const languageOptions = parserOptions ? { parserOptions } : undefined;
214
+ return { ...rest, languageOptions };
215
+ }
216
+
217
+ private handlePlugins({
218
+ flatConfigs,
219
+ imports,
220
+ source,
221
+ }: Context & { source: { plugins: NonNullable<Context['source']['plugins']> } }) {
222
+ const plugins: Record<string, string> = {};
223
+
224
+ source.plugins.forEach(name => {
225
+ const camelized = toCamelCase(name);
226
+ imports[`eslint-plugin-${name}`] = camelized;
227
+ plugins[name] = camelized;
228
+ });
229
+
230
+ flatConfigs.push(`{
231
+ plugins: {
232
+ ${Object.entries(plugins)
233
+ .map(([key, value]) => `'${key}': ${value}`)
234
+ .join(',\n')}
235
+ }
236
+ }`);
237
+ }
238
+
239
+ private handleParserOptions({ flatConfigs, source: { parserOptions } }: Context) {
240
+ flatConfigs.push(JSON.stringify({ languageOptions: { parserOptions } }));
241
+ }
242
+
243
+ private handleRules({
244
+ flatConfigs,
245
+ source: { rules },
246
+ }: Context & { source: { rules: NonNullable<Context['source']['rules']> } }) {
247
+ const tsPrefix = '@typescript-eslint/';
248
+ const nonTsRules = Object.fromEntries(
249
+ Object.entries(rules).filter(([key]) => !key.startsWith(tsPrefix))
250
+ );
251
+ if (Object.keys(nonTsRules).length) {
252
+ flatConfigs.push(JSON.stringify({ rules: nonTsRules }, null, 2));
253
+ }
254
+
255
+ const tsRules = Object.fromEntries(
256
+ Object.entries(rules).filter(([key]) => key.startsWith(tsPrefix))
257
+ );
258
+ if (Object.keys(tsRules).length) {
259
+ flatConfigs.push(JSON.stringify({ files: ['**/*.ts{,x}'], rules: tsRules }, null, 2));
260
+ }
261
+ }
262
+
263
+ private handleSettings({
264
+ flatConfigs,
265
+ source: { settings },
266
+ }: Context & { source: { settings: NonNullable<Context['source']['settings']> } }) {
267
+ flatConfigs.push(JSON.stringify({ settings }));
268
+ }
269
+
270
+ private async prettify(filename: string) {
271
+ try {
272
+ await execa('npx', ['prettier', '-w', filename], { stdout: 'ignore' });
273
+ } catch (error: any) {
274
+ throw new Error(`prettier failed with exit code ${error.exitCode}`);
275
+ }
276
+ }
277
+
278
+ private readJson(filename: string) {
279
+ return JSON5.parse(fs.readFileSync(filename, 'utf8'));
280
+ }
281
+ }
282
+
283
+ function toCamelCase(str: string) {
284
+ return str.replace(/(-[a-z])/g, ([, letter]) => letter.toUpperCase());
285
+ }
286
+
287
+ function normalizeGlob(glob: string) {
288
+ return glob.includes('/') ? glob.replace(/^(!)?\//, '$1') : `**/${glob}`;
289
+ }
@@ -2,6 +2,7 @@ import { CommandName } from '../../utils';
2
2
 
3
3
  import { Build } from './build';
4
4
  import { BundlePackage } from './bundle-package';
5
+ import { ConvertEslintConfig } from './convert-eslint-config';
5
6
  import { ESLintCommand } from './eslint';
6
7
  import { Init } from './init';
7
8
  import { Install } from './install';
@@ -13,6 +14,7 @@ import { MFEPublish } from './mfe-publish';
13
14
  import { PreparePackage } from './prepare-package';
14
15
  import { Start } from './start';
15
16
  import { StylesCheck } from './styles-check';
17
+ import { RunTask } from './run-task';
16
18
  import { Tests } from './tests';
17
19
  import { Command, Newable } from './types';
18
20
 
@@ -22,6 +24,8 @@ export function getCommand(name: CommandName): Newable<Command> | undefined {
22
24
  return Build;
23
25
  case CommandName['bundle-package']:
24
26
  return BundlePackage;
27
+ case CommandName['convert-eslint-config']:
28
+ return ConvertEslintConfig;
25
29
  case CommandName.eslint:
26
30
  return ESLintCommand;
27
31
  case CommandName.init:
@@ -46,5 +50,7 @@ export function getCommand(name: CommandName): Newable<Command> | undefined {
46
50
  return StylesCheck;
47
51
  case CommandName.test:
48
52
  return Tests;
53
+ case CommandName.task:
54
+ return RunTask;
49
55
  }
50
56
  }
@@ -37,7 +37,8 @@ export class Init implements Command {
37
37
  );
38
38
  }
39
39
 
40
- for await (const url of gitUrls) {
40
+ for (const url of gitUrls) {
41
+ // eslint-disable-next-line no-await-in-loop
41
42
  if (await cloneRepo(url, destination)) {
42
43
  log.info(`copied example project to ${destination}`);
43
44
  return;
@@ -53,7 +54,7 @@ export class Init implements Command {
53
54
  async function cloneRepo(url: string, destination: string) {
54
55
  try {
55
56
  await runCommand(`git clone -q ${url} ${destination}`, { quiet: true });
56
- } catch (e) {
57
+ } catch {
57
58
  return false;
58
59
  }
59
60
  fs.rmSync(path.join(destination, '.git'), { recursive: true, force: true });
@@ -65,7 +66,7 @@ async function cloneRepo(url: string, destination: string) {
65
66
  function isReachable(url: string) {
66
67
  try {
67
68
  runCommandOutput(`git ls-remote -qt ${url}`, { quiet: true });
68
- } catch (e) {
69
+ } catch {
69
70
  return false;
70
71
  }
71
72
  return true;
@@ -22,7 +22,7 @@ interface Args {
22
22
  /** Packages to skip */
23
23
  ignore?: string | string[];
24
24
  /** Run eslint separately for each package? */
25
- isolated?: boolean;
25
+ parallel?: boolean;
26
26
  }
27
27
 
28
28
  export class Lint implements Command {
@@ -44,10 +44,10 @@ export class Lint implements Command {
44
44
 
45
45
  @logErrors
46
46
  private async eslint() {
47
- const { fix, ignore, isolated, scope } = this.args;
47
+ const { fix, ignore, parallel, scope } = this.args;
48
48
  const { paths } = this;
49
49
 
50
- const useESLint = !isolated && !scope?.length && !ignore?.length;
50
+ const useESLint = !parallel && !scope?.length && !ignore?.length;
51
51
  if (this.paths.length || useESLint) {
52
52
  log.info('Running eslint...');
53
53
  await eslint({ fix, paths });
@@ -0,0 +1,41 @@
1
+ import { log, logErrors } from '../../utils';
2
+ import { Command } from './types';
3
+ import { SwcCompilePackage } from '../tasks/swc-compile-package';
4
+ import { TscCompilePackage } from '../tasks/tsc-compile-package';
5
+ import { TscCompile } from '../tasks/tsc-compile';
6
+ import { Task } from '../tasks/task';
7
+
8
+ interface Args {
9
+ [key: string]: unknown;
10
+ // eslint-disable-next-line @typescript-eslint/naming-convention
11
+ _: string[];
12
+ }
13
+
14
+ const tasks: Record<string, new (args: Args) => Task> = {
15
+ 'tsc-compile-package': TscCompilePackage,
16
+ 'swc-compile-package': SwcCompilePackage,
17
+ 'tsc-compile': TscCompile,
18
+ };
19
+
20
+ export class RunTask implements Command {
21
+ constructor(private readonly args: Args) {}
22
+
23
+ description() {
24
+ return undefined;
25
+ }
26
+
27
+ @logErrors
28
+ async execute() {
29
+ const taskName = this.args._[0];
30
+
31
+ if (taskName in tasks) {
32
+ // eslint-disable-next-line @typescript-eslint/naming-convention
33
+ const trimArgs = { ...this.args, _: this.args._.slice(1) };
34
+ await new tasks[taskName](trimArgs).execute();
35
+ return;
36
+ }
37
+
38
+ log.error(`Unknown task: "${taskName}"`);
39
+ log.text(`\nSupported tasks: "${Object.keys(tasks).join('", "')}"`);
40
+ }
41
+ }
package/src/cli/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import execa from 'execa';
2
+ import path from 'path';
2
3
  import { argv, Arguments } from 'yargs';
3
4
  import { CommandName, getStartupVersion, log } from '../utils';
4
5
  import { getCommand, getUserCommands } from './commands';
@@ -20,6 +21,7 @@ if (!Command) {
20
21
  process.exit(127);
21
22
  }
22
23
 
24
+ checkNodeVersion();
23
25
  maybeCreateGitFolder(Command);
24
26
 
25
27
  if (setNodeOptions(name)) {
@@ -36,15 +38,25 @@ if (setNodeOptions(name)) {
36
38
  }
37
39
 
38
40
  function usage() {
39
- write('\nUsage:\n');
41
+ log.text('\nUsage:');
40
42
 
41
43
  const commands = getUserCommands().filter(({ name }) => !!name);
42
44
  const maxNameLength = commands.reduce((result, { name }) => Math.max(result, name.length), 0);
43
45
  commands.forEach(({ name, description }) => {
44
- write(`startup ${name.padEnd(maxNameLength, ' ')} ${description}`);
46
+ log.text(`startup ${name.padEnd(maxNameLength, ' ')} ${description}`);
45
47
  });
46
48
  }
47
49
 
48
- function write(text: string) {
49
- return console.info(text); // eslint-disable-line no-console
50
+ function checkNodeVersion() {
51
+ const nodeVersion = Number(process.versions.node.split('.')[0]);
52
+ if (nodeVersion % 2 === 0 || process.env.SKIP_NODE_VERSION_CHECK) {
53
+ return;
54
+ }
55
+
56
+ const { engines } = require(path.join(__dirname, '../../package.json'));
57
+ log.error(
58
+ `error: node v${nodeVersion} detected, only even-numbered LTS versions ${engines.node} are supported`
59
+ );
60
+ log.text('See https://nodejs.org/en/download for LTS versions');
61
+ process.exit(127);
50
62
  }
@@ -0,0 +1,115 @@
1
+ import execa from 'execa';
2
+ import { log } from '../../../utils';
3
+ import { CliTask, Indicators } from '../cli-task';
4
+
5
+ jest.mock('execa', () =>
6
+ jest.fn().mockReturnValue(
7
+ Object.assign(Promise.resolve(), {
8
+ stdout: {
9
+ on: jest.fn(),
10
+ },
11
+ })
12
+ )
13
+ );
14
+
15
+ jest.mock('../../../utils', () => ({
16
+ getFolders: jest.fn().mockReturnValue({
17
+ source: 'src',
18
+ destination: 'dist',
19
+ }),
20
+ log: { info: jest.fn() },
21
+ readJsonSafe: jest.fn().mockReturnValue({}),
22
+ }));
23
+
24
+ const taskName = 'cli-task-impl';
25
+ const indicators: Required<Indicators> = {
26
+ end: 'Watching for file changes.',
27
+ watchStart: 'Starting incremental compilation.',
28
+ watchEnd: 'Watching changes.',
29
+ };
30
+
31
+ class CliTaskImpl extends CliTask {
32
+ constructor({ watch }: { watch: boolean }) {
33
+ super({ name: taskName, global: false, watch, indicators });
34
+ }
35
+
36
+ description() {
37
+ return '';
38
+ }
39
+
40
+ async execute(): Promise<void> {
41
+ await this.runChildProcess('', []);
42
+ }
43
+ }
44
+
45
+ describe(`[startup] ${CliTask.name} task`, () => {
46
+ beforeEach(() => {
47
+ globalThis.performance.clearMarks();
48
+ globalThis.performance.clearMeasures();
49
+ jest.clearAllMocks();
50
+ });
51
+
52
+ let watch = false;
53
+
54
+ const subject = () => new CliTaskImpl({ watch }).execute();
55
+
56
+ test('logs completion message', async () => {
57
+ await subject();
58
+
59
+ expect(log.info).toHaveBeenCalledWith(
60
+ expect.stringContaining(`${taskName} task completed in`)
61
+ );
62
+ });
63
+
64
+ describe('in watch mode', () => {
65
+ beforeEach(() => (watch = true));
66
+
67
+ describe('onData handler', () => {
68
+ beforeEach(() => subject());
69
+
70
+ const onDataHandler = (data: string) => {
71
+ jest.mocked(jest.mocked(execa).mock.results[0].value.stdout.on).mock.calls[0][1](
72
+ data
73
+ );
74
+ };
75
+
76
+ function itLogsInitialBuild(event: keyof Indicators) {
77
+ test('logs duration of initial build', () => {
78
+ onDataHandler(indicators[event]);
79
+
80
+ expect(log.info).toHaveBeenCalledWith(
81
+ expect.stringContaining(`Initial ${taskName} task completed in`)
82
+ );
83
+ });
84
+ }
85
+
86
+ function itLogsSubsequentBuild(event: keyof Indicators) {
87
+ test('logs duration of subsequent build', () => {
88
+ onDataHandler(indicators[event]!);
89
+
90
+ expect(log.info).toHaveBeenCalledWith(
91
+ expect.stringContaining(`Subsequent ${taskName} task completed in`)
92
+ );
93
+ });
94
+ }
95
+
96
+ itLogsInitialBuild('end');
97
+
98
+ describe('when subsequent build starts after initial build ends', () => {
99
+ beforeEach(() => {
100
+ onDataHandler(indicators.end);
101
+ onDataHandler(indicators.watchStart);
102
+ });
103
+
104
+ itLogsSubsequentBuild('end');
105
+ itLogsSubsequentBuild('watchEnd');
106
+ });
107
+
108
+ describe('when subsequent build starts before initial build ends', () => {
109
+ beforeEach(() => onDataHandler(indicators.watchStart));
110
+
111
+ itLogsSubsequentBuild('watchEnd');
112
+ });
113
+ });
114
+ });
115
+ });