@salesforce/pwa-kit-create-app 3.0.0-preview.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 (31) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +44 -0
  3. package/assets/bootstrap/js/.eslintignore +4 -0
  4. package/assets/bootstrap/js/.eslintrc.js +10 -0
  5. package/assets/bootstrap/js/.prettierrc.yaml +7 -0
  6. package/assets/bootstrap/js/babel.config.js +7 -0
  7. package/assets/bootstrap/js/config/default.js.hbs +77 -0
  8. package/assets/bootstrap/js/config/sites.js.hbs +26 -0
  9. package/assets/bootstrap/js/overrides/app/assets/svg/brand-logo.svg +5 -0
  10. package/assets/bootstrap/js/overrides/app/main.jsx +14 -0
  11. package/assets/bootstrap/js/overrides/app/request-processor.js +118 -0
  12. package/assets/bootstrap/js/overrides/app/routes.jsx.hbs +19 -0
  13. package/assets/bootstrap/js/overrides/app/ssr.js +67 -0
  14. package/assets/bootstrap/js/overrides/app/static/ico/favicon.ico +0 -0
  15. package/assets/bootstrap/js/overrides/app/static/img/global/app-icon-192.png +0 -0
  16. package/assets/bootstrap/js/overrides/app/static/img/global/app-icon-512.png +0 -0
  17. package/assets/bootstrap/js/overrides/app/static/img/global/apple-touch-icon.png +0 -0
  18. package/assets/bootstrap/js/overrides/app/static/img/hero.png +0 -0
  19. package/assets/bootstrap/js/overrides/app/static/manifest.json.hbs +19 -0
  20. package/assets/bootstrap/js/overrides/app/static/robots.txt +2 -0
  21. package/assets/bootstrap/js/package.json.hbs +36 -0
  22. package/assets/bootstrap/js/worker/main.js +6 -0
  23. package/assets/templates/retail-react-app/app/static/manifest.json.hbs +19 -0
  24. package/assets/templates/retail-react-app/config/default.js.hbs +77 -0
  25. package/assets/templates/retail-react-app/config/sites.js.hbs +26 -0
  26. package/package.json +50 -0
  27. package/scripts/create-mobify-app.js +764 -0
  28. package/templates/express-minimal.tar.gz +0 -0
  29. package/templates/mrt-reference-app.tar.gz +0 -0
  30. package/templates/retail-react-app.tar.gz +0 -0
  31. package/templates/typescript-minimal.tar.gz +0 -0
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Copyright (c) 2023, Salesforce, Inc.
4
+ * All rights reserved.
5
+ * SPDX-License-Identifier: BSD-3-Clause
6
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
7
+ */
8
+ /* eslint-disable @typescript-eslint/no-var-requires */
9
+
10
+ /**
11
+ * This is a generator for PWA Kit projects that run on the Managed Runtime.
12
+ *
13
+ * The output of this script is a copy of a project template with the following changes:
14
+ *
15
+ * 1) We update any monorepo-local dependencies to be installed through NPM.
16
+ *
17
+ * 2) We rename the template and configure the generated project based on answers to
18
+ * questions that we ask the user on the CLI.
19
+ *
20
+ * ## Basic usage
21
+ *
22
+ * We expect end-users to generate projects by running `npx @salesforce/pwa-kit-create-app` on
23
+ * the CLI and following the prompts. Users must be able to run that command without
24
+ * installing any dependencies first.
25
+ *
26
+ * ## Advanced usage and integration testing:
27
+ *
28
+ * For testing on CI we need to be able to generate projects without running
29
+ * the interactive prompts on the CLI. To support these cases, we have
30
+ * a few presets that are "private" and only usable through the GENERATOR_PRESET
31
+ * env var – this keeps them out of the --help docs.
32
+ *
33
+ * If both the GENERATOR_PRESET env var and --preset arguments are passed, the
34
+ * option set in --preset is used.
35
+ */
36
+
37
+ const p = require('path')
38
+ const fs = require('fs')
39
+ const os = require('os')
40
+ const child_proc = require('child_process')
41
+ const {Command} = require('commander')
42
+ const inquirer = require('inquirer')
43
+ const {URL} = require('url')
44
+ const deepmerge = require('deepmerge')
45
+ const sh = require('shelljs')
46
+ const tar = require('tar')
47
+ const semver = require('semver')
48
+ const slugify = require('slugify')
49
+ const generatorPkg = require('../package.json')
50
+ const Handlebars = require('handlebars')
51
+
52
+ const program = new Command()
53
+
54
+ sh.set('-e')
55
+
56
+ // Handlebars helpers
57
+
58
+ // Our eslint script uses exscaped double quotes to have windows compatibility. This helper
59
+ // will ensure those escaped double quotes are still escaped after processing the template.
60
+ Handlebars.registerHelper('script', (object) => object.replaceAll('"', '\\"'))
61
+
62
+ // Validations
63
+ const validPreset = (preset) => {
64
+ return ALL_PRESET_NAMES.includes(preset)
65
+ }
66
+
67
+ const validProjectName = (s) => {
68
+ const regex = new RegExp(`^[a-zA-Z0-9-\\s]{1,${PROJECT_ID_MAX_LENGTH}}$`)
69
+ return regex.test(s) || 'Value can only contain letters, numbers, space and hyphens.'
70
+ }
71
+
72
+ const validUrl = (s) => {
73
+ try {
74
+ new URL(s)
75
+ return true
76
+ } catch (err) {
77
+ return 'Value must be an absolute URL'
78
+ }
79
+ }
80
+
81
+ const validSiteId = (s) =>
82
+ /^[a-z0-9_-]+$/i.test(s) || 'Valid characters are alphanumeric, hyphen, or underscore'
83
+
84
+ // To see definitions for Commerce API configuration values, go to
85
+ // https://developer.salesforce.com/docs/commerce/commerce-api/guide/commerce-api-configuration-values.
86
+ const defaultCommerceAPIError =
87
+ 'Invalid format. Use docs to find more information about valid configurations: https://developer.salesforce.com/docs/commerce/commerce-api/guide/commerce-api-configuration-values'
88
+ const validShortCode = (s) => /(^[0-9A-Z]{8}$)/i.test(s) || defaultCommerceAPIError
89
+
90
+ const validClientId = (s) =>
91
+ /(^[0-9A-Z]{8}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{12}$)/i.test(s) ||
92
+ s === 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ||
93
+ defaultCommerceAPIError
94
+ const validOrganizationId = (s) =>
95
+ /^(f_ecom)_([A-Z]{4})_(prd|stg|dev|[0-9]{3}|s[0-9]{2})$/i.test(s) || defaultCommerceAPIError
96
+
97
+ // Globals
98
+ const GENERATED_PROJECT_VERSION = '0.0.1'
99
+
100
+ const INITIAL_CONTEXT = {
101
+ preset: undefined,
102
+ answers: {
103
+ general: {},
104
+ project: {}
105
+ }
106
+ }
107
+ const TEMPLATE_SOURCE_NPM = 'npm'
108
+ const TEMPLATE_SOURCE_BUNDLE = 'bundle'
109
+ const DEFAULT_TEMPLATE_VERSION = 'latest'
110
+
111
+ const EXTENSIBILITY_QUESTIONS = [
112
+ {
113
+ name: 'project.extend',
114
+ message: 'Do you wish to use template extensibility?',
115
+ type: 'list',
116
+ choices: [
117
+ {
118
+ name: 'No',
119
+ value: false
120
+ },
121
+ {
122
+ name: 'Yes',
123
+ value: true
124
+ }
125
+ ]
126
+ }
127
+ ]
128
+
129
+ const MRT_REFERENCE_QUESTIONS = [
130
+ {
131
+ name: 'project.name',
132
+ validate: validProjectName,
133
+ message: 'What is the name of your Project?'
134
+ }
135
+ ]
136
+
137
+ const EXPRESS_MINIMAL_QUESTIONS = [
138
+ {
139
+ name: 'project.name',
140
+ validate: validProjectName,
141
+ message: 'What is the name of your Project?'
142
+ }
143
+ ]
144
+
145
+ const TYPESCRIPT_MINIMAL_QUESTIONS = [
146
+ {
147
+ name: 'project.name',
148
+ validate: validProjectName,
149
+ message: 'What is the name of your Project?'
150
+ }
151
+ ]
152
+
153
+ const RETAIL_REACT_APP_QUESTIONS = [
154
+ {
155
+ name: 'project.name',
156
+ validate: validProjectName,
157
+ message: 'What is the name of your Project?'
158
+ },
159
+ {
160
+ name: 'project.commerce.instanceUrl',
161
+ message: 'What is the URL for your Commerce Cloud instance?',
162
+ validate: validUrl
163
+ },
164
+ {
165
+ name: 'project.commerce.clientId',
166
+ message: 'What is your SLAS Client ID?',
167
+ validate: validClientId
168
+ },
169
+ {
170
+ name: 'project.commerce.siteId',
171
+ message: 'What is your Site ID in Business Manager?',
172
+ validate: validSiteId
173
+ },
174
+ {
175
+ name: 'project.commerce.organizationId',
176
+ message: 'What is your Commerce API organization ID in Business Manager?',
177
+ validate: validOrganizationId
178
+ },
179
+ {
180
+ name: 'project.commerce.shortCode',
181
+ message: 'What is your Commerce API short code in Business Manager?',
182
+ validate: validShortCode
183
+ }
184
+ ]
185
+
186
+ // Project dictionary describing details and how the gerator should ask questions etc.
187
+ const PRESETS = [
188
+ {
189
+ id: 'retail-react-app',
190
+ name: 'Retail React App',
191
+ description: `
192
+ Generate a project using custom settings by answering questions about a
193
+ B2C Commerce instance.
194
+
195
+ Use this preset to connect to an existing instance, such as a sandbox.
196
+ `,
197
+ shortDescription: 'The Retail app using your own Commerce Cloud instance',
198
+ templateSource: {
199
+ type: TEMPLATE_SOURCE_NPM,
200
+ id: '@salesforce/retail-react-app'
201
+ },
202
+ questions: [...EXTENSIBILITY_QUESTIONS, ...RETAIL_REACT_APP_QUESTIONS],
203
+ assets: ['translations'],
204
+ private: false
205
+ },
206
+ {
207
+ id: 'retail-react-app-demo',
208
+ name: 'Retail React App Demo',
209
+ description: `
210
+ Generate a project using the settings for a special B2C Commerce
211
+ instance that is used for demo purposes. No questions are asked.
212
+
213
+ Use this preset to try out PWA Kit.
214
+ `,
215
+ shortDescription: 'The Retail app with demo Commerce Cloud instance',
216
+ templateSource: {
217
+ type: TEMPLATE_SOURCE_NPM,
218
+ id: '@salesforce/retail-react-app'
219
+ },
220
+ questions: [...EXTENSIBILITY_QUESTIONS, ...RETAIL_REACT_APP_QUESTIONS],
221
+ answers: {
222
+ ['project.extend']: true,
223
+ ['project.name']: 'demo-storefront',
224
+ ['project.commerce.instanceUrl']: 'https://zzte-053.dx.commercecloud.salesforce.com',
225
+ ['project.commerce.clientId']: '1d763261-6522-4913-9d52-5d947d3b94c4',
226
+ ['project.commerce.siteId']: 'RefArch',
227
+ ['project.commerce.organizationId']: 'f_ecom_zzte_053',
228
+ ['project.commerce.shortCode']: 'kv7kzm78',
229
+ ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1',
230
+ ['project.einstein.siteId']: 'aaij-MobileFirst'
231
+ },
232
+ assets: ['translations'],
233
+ private: false
234
+ },
235
+ {
236
+ id: 'retail-react-app-test-project',
237
+ name: 'Retail React App Test Project',
238
+ description: '',
239
+ templateSource: {
240
+ type: TEMPLATE_SOURCE_NPM,
241
+ id: '@salesforce/retail-react-app'
242
+ },
243
+ questions: [...EXTENSIBILITY_QUESTIONS, ...RETAIL_REACT_APP_QUESTIONS],
244
+ answers: {
245
+ ['project.extend']: true,
246
+ ['project.name']: 'retail-react-app',
247
+ ['project.commerce.instanceUrl']: 'https://zzrf-001.dx.commercecloud.salesforce.com',
248
+ ['project.commerce.clientId']: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836',
249
+ ['project.commerce.siteId']: 'RefArch',
250
+ ['project.commerce.organizationId']: 'f_ecom_zzrf_001',
251
+ ['project.commerce.shortCode']: 'kv7kzm78',
252
+ ['project.einstein.clientId']: '1ea06c6e-c936-4324-bcf0-fada93f83bb1',
253
+ ['project.einstein.siteId']: 'aaij-MobileFirst'
254
+ },
255
+ assets: ['translations'],
256
+ private: true
257
+ },
258
+ {
259
+ id: 'typescript-minimal-test-project',
260
+ name: 'Template Minimal Test Project',
261
+ description: '',
262
+ templateSource: {
263
+ type: TEMPLATE_SOURCE_BUNDLE,
264
+ id: 'typescript-minimal'
265
+ },
266
+ private: true
267
+ },
268
+ {
269
+ id: 'typescript-minimal',
270
+ name: 'Template Minimal Project',
271
+ description: `
272
+ Generate a project using a bare-bones TypeScript app template.
273
+
274
+ Use this as a TypeScript starting point or as a base on top of
275
+ which to build new TypeScript project templates for Managed Runtime.
276
+ `,
277
+ templateSource: {
278
+ type: TEMPLATE_SOURCE_BUNDLE,
279
+ id: 'typescript-minimal'
280
+ },
281
+ questions: TYPESCRIPT_MINIMAL_QUESTIONS,
282
+ private: true
283
+ },
284
+ {
285
+ id: 'express-minimal-test-project',
286
+ name: 'Express Minimal Test Project',
287
+ description: '',
288
+ templateSource: {
289
+ type: TEMPLATE_SOURCE_BUNDLE,
290
+ id: 'express-minimal'
291
+ },
292
+ questions: EXPRESS_MINIMAL_QUESTIONS,
293
+ answers: {
294
+ ['project.name']: 'express-minimal'
295
+ },
296
+ private: true
297
+ },
298
+ {
299
+ id: 'express-minimal',
300
+ name: 'Express Minimal Project',
301
+ description: `
302
+ Generate a project using a bare-bones express app template.
303
+
304
+ Use this as a starting point for APIs or as a base on top of
305
+ which to build new project templates for Managed Runtime.
306
+ `,
307
+ templateSource: {
308
+ type: TEMPLATE_SOURCE_BUNDLE,
309
+ id: 'express-minimal'
310
+ },
311
+ questions: EXPRESS_MINIMAL_QUESTIONS,
312
+ private: true
313
+ },
314
+ {
315
+ id: 'mrt-reference-app',
316
+ name: 'Managed Runtime Reference App',
317
+ description: '',
318
+ templateSource: {
319
+ type: TEMPLATE_SOURCE_BUNDLE,
320
+ id: 'mrt-reference-app'
321
+ },
322
+ questions: MRT_REFERENCE_QUESTIONS,
323
+ answers: {
324
+ ['project.name']: 'mrt-reference-app'
325
+ },
326
+ private: true
327
+ }
328
+ ]
329
+
330
+ const PRESET_QUESTIONS = [
331
+ {
332
+ name: 'general.presetId',
333
+ message: 'Choose a project preset to get started:',
334
+ type: 'list',
335
+ choices: PRESETS.filter(({private}) => !private).map(({shortDescription, id}) => ({
336
+ name: shortDescription,
337
+ value: id
338
+ }))
339
+ }
340
+ ]
341
+
342
+ const BOOTSTRAP_DIR = p.join(__dirname, '..', 'assets', 'bootstrap', 'js')
343
+
344
+ const ASSETS_TEMPLATES_DIR = p.join(__dirname, '..', 'assets', 'templates')
345
+
346
+ const PRIVATE_PRESET_NAMES = PRESETS.filter(({private}) => !!private).map(({id}) => id)
347
+
348
+ const PUBLIC_PRESET_NAMES = PRESETS.filter(({private}) => !private).map(({id}) => id)
349
+
350
+ const ALL_PRESET_NAMES = PRIVATE_PRESET_NAMES.concat(PUBLIC_PRESET_NAMES)
351
+
352
+ const PROJECT_ID_MAX_LENGTH = 20
353
+
354
+ // Utilities
355
+
356
+ const readJson = (path) => JSON.parse(sh.cat(path))
357
+
358
+ const writeJson = (path, data) => new sh.ShellString(JSON.stringify(data, null, 2)).to(path)
359
+
360
+ const slugifyName = (name) =>
361
+ slugify(name, {
362
+ lower: true,
363
+ strict: true
364
+ }).slice(0, PROJECT_ID_MAX_LENGTH)
365
+
366
+ /**
367
+ * Check if the provided path is an empty directory.
368
+ * @param {*} path
369
+ * @returns
370
+ */
371
+ const isDirEmpty = (path) => fs.readdirSync(path).length === 0
372
+
373
+ /**
374
+ * Logs an error and exits the process if the provided path points at a
375
+ * non-empty directory.
376
+ *
377
+ * @param {*} path
378
+ */
379
+ const checkOutputDir = (path) => {
380
+ if (sh.test('-e', path) && !isDirEmpty(path)) {
381
+ console.error(
382
+ `The output directory "${path}" already exists. Try, for example, ` +
383
+ `"~/Desktop/my-project" instead of "~/Desktop"`
384
+ )
385
+ process.exit(1)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Returns a list of absolute file paths for a given folder. This will recursively
391
+ * list files in child folders.
392
+ *
393
+ * @param {*} dirPath
394
+ * @param {*} arrayOfFiles
395
+ * @returns
396
+ */
397
+ const getFiles = (dirPath, arrayOfFiles = []) => {
398
+ const files = fs.readdirSync(dirPath)
399
+
400
+ files.forEach((file) => {
401
+ if (fs.statSync(p.join(dirPath, file)).isDirectory()) {
402
+ arrayOfFiles = getFiles(p.join(dirPath, file), arrayOfFiles)
403
+ } else {
404
+ arrayOfFiles.push(p.join(dirPath, file))
405
+ }
406
+ })
407
+
408
+ return arrayOfFiles
409
+ }
410
+
411
+ /**
412
+ * Deeply merge two objects in such a way that all array entries in b replace array
413
+ * entries in a, eg:
414
+ *
415
+ * merge(
416
+ * {foo: 'foo', items: [{thing: 'a'}]},
417
+ * {bar: 'bar', items: [{thing: 'b'}]}
418
+ * )
419
+ * > {foo: 'foo', bar: 'bar', items: [{thing: 'b'}]}
420
+ *
421
+ * @param a
422
+ * @param b
423
+ * @return {*}
424
+ */
425
+ const merge = (a, b) => deepmerge(a, b, {arrayMerge: (orignal, replacement) => replacement})
426
+
427
+ /**
428
+ * Provided a dot notation key, and a value, return an expanded object splitting
429
+ * the key.
430
+ *
431
+ * @example
432
+ * const expandedObj = expand('parent.child.grandchild': { name: 'Preseley' })
433
+ * console.log(expandedObj) // {parent: { child: {grandchild: {name: 'Presley}}}}
434
+ *
435
+ * @param {string} key
436
+ * @param {Object} value
437
+ * @returns
438
+ *
439
+ */
440
+ const expandKey = (key, value) =>
441
+ key
442
+ .split('.')
443
+ .reverse()
444
+ .reduce(
445
+ (acc, curr) =>
446
+ acc
447
+ ? {
448
+ [curr]: acc
449
+ }
450
+ : {
451
+ [curr]: value
452
+ },
453
+ undefined
454
+ )
455
+
456
+ /**
457
+ * Provided an object there the keys use "dot notation", expand each individual key.
458
+ * NOTE: This only expands keys at the root level, and not those nested.
459
+ *
460
+ * @example
461
+ * const expandedObj = expand({'coolthings.babynames': 'Preseley', 'coolthings.cars': 'bmws'})
462
+ * console.log(expandedObj) // {coolthings: { babynames: 'Presley', cars: 'bmws'}}
463
+ *
464
+ * @param {Object} answers
465
+ * @returns {Object} The expanded object.
466
+ *
467
+ */
468
+ const expandObject = (obj = {}) =>
469
+ Object.keys(obj).reduce((acc, curr) => merge(acc, expandKey(curr, obj[curr])), {})
470
+
471
+ /**
472
+ * Envoke the "npm install" command for the provided project directory.
473
+ *
474
+ * @param {*} outputDir
475
+ * @param {*} param1
476
+ */
477
+ const npmInstall = (outputDir, {verbose}) => {
478
+ console.log('Installing dependencies... This may take a few minutes.\n')
479
+ const npmLogLevel = verbose ? 'notice' : 'error'
480
+ const disableStdOut = ['inherit', 'ignore', 'inherit']
481
+ const stdio = verbose ? 'inherit' : disableStdOut
482
+ try {
483
+ child_proc.execSync(`npm install --color always --loglevel ${npmLogLevel}`, {
484
+ cwd: outputDir,
485
+ stdio,
486
+ env: {
487
+ ...process.env,
488
+ OPENCOLLECTIVE_HIDE: 'true',
489
+ DISABLE_OPENCOLLECTIVE: 'true',
490
+ OPEN_SOURCE_CONTRIBUTOR: 'true'
491
+ }
492
+ })
493
+ } catch {
494
+ // error is already displayed on the console by child process.
495
+ // exit the program
496
+ process.exit(1)
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Execute and copy the handlebars template to the output directory using
502
+ * the provided context object. If the file isn't a template, simply copy
503
+ * it to the destination.
504
+ *
505
+ * @param {string} inputFile
506
+ * @param {string} outputDir
507
+ * @param {Object} context
508
+ */
509
+ const processTemplate = (relFile, inputDir, outputDir, context) => {
510
+ const inputFile = p.join(inputDir, relFile)
511
+ const outputFile = p.join(outputDir, relFile)
512
+ const destDir = p.join(outputFile, '..')
513
+
514
+ // Create folder if we are doing a deep copy
515
+ if (destDir) {
516
+ fs.mkdirSync(destDir, {recursive: true})
517
+ }
518
+
519
+ if (inputFile.endsWith('.hbs')) {
520
+ const template = sh.cat(inputFile).stdout
521
+ fs.writeFileSync(outputFile.replace('.hbs', ''), Handlebars.compile(template)(context))
522
+ } else {
523
+ fs.copyFileSync(inputFile, outputFile)
524
+ }
525
+ }
526
+
527
+ /**
528
+ * This function does the bulk of the project generation given the project config
529
+ * object and the answers returned from the survey process.
530
+ *
531
+ * @param {*} preset
532
+ * @param {*} answers
533
+ * @param {*} param2
534
+ */
535
+ const runGenerator = (context, {outputDir, templateVersion, verbose}) => {
536
+ const {answers, preset} = context
537
+ const {templateSource} = preset
538
+ const {extend = false} = answers.project
539
+
540
+ // Check if the output directory doesn't already exist.
541
+ checkOutputDir(outputDir)
542
+
543
+ // We need to get some assets from the base template. So extract it after
544
+ // downloading from NPM or copying from the template bundle folder.
545
+ const tmp = fs.mkdtempSync(p.resolve(os.tmpdir(), 'extract-template'))
546
+ const packagePath = p.join(tmp, 'package')
547
+ const {id, type} = templateSource
548
+ let tarPath
549
+
550
+ switch (type) {
551
+ case TEMPLATE_SOURCE_NPM: {
552
+ const tarFile = sh
553
+ .exec(`npm pack ${id}@${templateVersion} --pack-destination="${tmp}"`, {
554
+ silent: true
555
+ })
556
+ .stdout.trim()
557
+ tarPath = p.join(tmp, tarFile)
558
+ break
559
+ }
560
+ case TEMPLATE_SOURCE_BUNDLE:
561
+ tarPath = p.join(__dirname, '..', 'templates', `${id}.tar.gz`)
562
+ break
563
+ default: {
564
+ const msg = `Error: Cannot handle template source type ${type}.`
565
+ console.error(msg)
566
+ process.exit(1)
567
+ }
568
+ }
569
+
570
+ // Extract the source
571
+ tar.x({
572
+ file: tarPath,
573
+ cwd: tmp,
574
+ sync: true
575
+ })
576
+
577
+ if (extend) {
578
+ // Bootstrap the projects.
579
+ getFiles(BOOTSTRAP_DIR)
580
+ .map((file) => file.replace(BOOTSTRAP_DIR, ''))
581
+ .forEach((relFilePath) =>
582
+ processTemplate(relFilePath, BOOTSTRAP_DIR, outputDir, context)
583
+ )
584
+
585
+ // Copy required assets defind on the preset level.
586
+ const {assets = []} = preset
587
+ assets.forEach((asset) => {
588
+ sh.cp('-rf', p.join(packagePath, asset), outputDir)
589
+ })
590
+ } else {
591
+ // Copy the base template either from the package or npm.
592
+ sh.cp('-rf', packagePath, outputDir)
593
+
594
+ // Copy template specific assets over.
595
+ const assetsDir = p.join(ASSETS_TEMPLATES_DIR, id)
596
+ if (sh.test('-e', assetsDir)) {
597
+ getFiles(assetsDir)
598
+ .map((file) => file.replace(assetsDir, ''))
599
+ .forEach((relFilePath) =>
600
+ processTemplate(relFilePath, assetsDir, outputDir, context)
601
+ )
602
+ }
603
+
604
+ // Update the generated projects version. NOTE: For bootstrapped projects this
605
+ // can be done in the template building. But since we have two types of project builds,
606
+ // (bootstrap/bundle) we'll do it here where it works in both scenarios.
607
+ const pkgJsonPath = p.resolve(outputDir, 'package.json')
608
+ const pkgJSON = readJson(pkgJsonPath)
609
+ const finalPkgData = merge(pkgJSON, {
610
+ name: slugifyName(context.answers.project.name || context.preset.id),
611
+ version: GENERATED_PROJECT_VERSION
612
+ })
613
+ writeJson(pkgJsonPath, finalPkgData)
614
+
615
+ // Clean up
616
+ sh.rm('-rf', tmp)
617
+ }
618
+
619
+ // Install dependencies for the newly minted project.
620
+ npmInstall(outputDir, {verbose})
621
+ }
622
+
623
+ const foundNode = process.versions.node
624
+ const requiredNode = generatorPkg.engines.node
625
+ const isUsingCompatibleNode = semver.satisfies(foundNode, new semver.Range(requiredNode))
626
+
627
+ const main = async (opts) => {
628
+ if (!isUsingCompatibleNode) {
629
+ console.log('')
630
+ console.warn(
631
+ `Warning: You are using Node ${foundNode}. ` +
632
+ `Your app may not work as expected when deployed to Managed ` +
633
+ `Runtime servers which are compatible with Node ${requiredNode}`
634
+ )
635
+ console.log('')
636
+ }
637
+
638
+ // The context object will have all the current information, like the selected preset, the answers
639
+ // to "general" and "project" questions. It'll also be populated with details of the selected project,
640
+ // like its `package.json` value.
641
+ let context = INITIAL_CONTEXT
642
+ let {outputDir, verbose, preset, templateVersion} = opts
643
+ const {prompt} = inquirer
644
+ const OUTPUT_DIR_FLAG_ACTIVE = !!outputDir
645
+ const presetId = preset || process.env.GENERATOR_PRESET
646
+
647
+ // Exit if the preset provided is not valid.
648
+ if (presetId && !validPreset(presetId)) {
649
+ console.error(
650
+ `The preset "${presetId}" is not valid. Valid presets are: ${
651
+ process.env.GENERATOR_PRESET
652
+ ? ALL_PRESET_NAMES.map((x) => `"${x}"`).join(' ')
653
+ : PUBLIC_PRESET_NAMES.map((x) => `"${x}"`).join(' ')
654
+ }.`
655
+ )
656
+ process.exit(1)
657
+ }
658
+
659
+ // If there is no preset arg, prompt the user with a selection of presets.
660
+ if (!presetId) {
661
+ context.answers = await prompt(PRESET_QUESTIONS)
662
+ }
663
+
664
+ // Add the selected preset to the context object.
665
+ const selectedPreset = PRESETS.find(
666
+ ({id}) => id === (presetId || context.answers.general.presetId)
667
+ )
668
+
669
+ // Add the preset to the context.
670
+ context.preset = selectedPreset
671
+
672
+ if (!OUTPUT_DIR_FLAG_ACTIVE) {
673
+ outputDir = p.join(process.cwd(), selectedPreset.id)
674
+ }
675
+
676
+ // Ask preset specific questions and merge into the current context.
677
+ const {questions = {}, answers = {}} = selectedPreset
678
+ if (questions) {
679
+ const projectAnswers = await prompt(questions, answers)
680
+
681
+ context = merge(context, {
682
+ answers: expandObject(projectAnswers)
683
+ })
684
+ }
685
+
686
+ // Inject the packageJSON into the context for extensibile projects.
687
+ if (context.answers.project.extend) {
688
+ const pkgJSON = JSON.parse(
689
+ sh.exec(`npm view ${selectedPreset.templateSource.id}@${templateVersion} --json`, {
690
+ silent: true
691
+ }).stdout
692
+ )
693
+
694
+ // NOTE: Here we are rewriting a specific script (extract-default-translations) in order
695
+ // to update the script location for extensibility. In the future we'll hopefully
696
+ // move transations outside of the template and into the sdk where the script for
697
+ // building translations will ultimately live, meaning we won't have to do this. So
698
+ // its OK for now.
699
+ if (pkgJSON?.scripts['extract-default-translations']) {
700
+ pkgJSON.scripts['extract-default-translations'] = pkgJSON.scripts[
701
+ 'extract-default-translations'
702
+ ].replace('./', `./node_modules/${selectedPreset.templateSource.id}/`)
703
+ }
704
+
705
+ context = merge(
706
+ context,
707
+ expandObject({
708
+ ['answers.general.packageJSON']: pkgJSON
709
+ })
710
+ )
711
+ }
712
+
713
+ // Generate the project.
714
+ runGenerator(context, {outputDir, templateVersion, verbose})
715
+
716
+ // Return the folder in which the project was generated in.
717
+ return outputDir
718
+ }
719
+
720
+ if (require.main === module) {
721
+ program.name(`pwa-kit-create-app`)
722
+ program.description(`Generate a new PWA Kit project, optionally using a preset.
723
+
724
+ Examples:
725
+
726
+ ${PRESETS.filter(({private}) => !private).map(({id, description}) => {
727
+ return `
728
+ ${program.name()} --preset "${id}"\n${description}
729
+ `
730
+ })}
731
+
732
+ `)
733
+ program
734
+ .option('--outputDir <path>', `Path to the output directory for the new project`)
735
+ .option(
736
+ '--preset <name>',
737
+ `The name of a project preset to use (choices: ${PUBLIC_PRESET_NAMES.map(
738
+ (x) => `"${x}"`
739
+ ).join(', ')})`
740
+ )
741
+ .option(
742
+ '--templateVersion <version>',
743
+ `The version of the template to be generated when it's source is NPM.`,
744
+ DEFAULT_TEMPLATE_VERSION
745
+ )
746
+ .option('--verbose', `Print additional logging information to the console.`, false)
747
+
748
+ program.parse(process.argv)
749
+
750
+ Promise.resolve()
751
+ .then(() => main(program.opts()))
752
+ .then((outputDir) => {
753
+ console.log('')
754
+ console.log(
755
+ `Successfully generated a project in ${outputDir ? outputDir : program.outputDir}`
756
+ )
757
+ process.exit(0)
758
+ })
759
+ .catch((err) => {
760
+ console.error('Failed to generate a project')
761
+ console.error(err)
762
+ process.exit(1)
763
+ })
764
+ }