@xpack/xpm-lib 3.1.2 → 4.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 (202) hide show
  1. package/README.md +16 -212
  2. package/dist/classes/actions.d.ts +58 -0
  3. package/dist/classes/actions.d.ts.map +1 -0
  4. package/dist/classes/actions.js +250 -0
  5. package/dist/classes/actions.js.map +1 -0
  6. package/dist/classes/build-configurations.d.ts +78 -0
  7. package/dist/classes/build-configurations.d.ts.map +1 -0
  8. package/dist/classes/build-configurations.js +489 -0
  9. package/dist/classes/build-configurations.js.map +1 -0
  10. package/dist/classes/combinations-generator.d.ts +19 -0
  11. package/dist/classes/combinations-generator.d.ts.map +1 -0
  12. package/dist/classes/combinations-generator.js +48 -0
  13. package/dist/classes/combinations-generator.js.map +1 -0
  14. package/dist/classes/data-model.d.ts +21 -0
  15. package/dist/classes/data-model.d.ts.map +1 -0
  16. package/dist/classes/data-model.js +47 -0
  17. package/dist/classes/data-model.js.map +1 -0
  18. package/dist/classes/errors.d.ts +13 -0
  19. package/dist/classes/errors.d.ts.map +1 -0
  20. package/dist/classes/errors.js +13 -0
  21. package/dist/classes/errors.js.map +1 -0
  22. package/dist/classes/init-template-base.d.ts +47 -0
  23. package/dist/classes/init-template-base.d.ts.map +1 -0
  24. package/dist/classes/init-template-base.js +358 -0
  25. package/dist/classes/init-template-base.js.map +1 -0
  26. package/dist/classes/liquid-drop.d.ts +28 -0
  27. package/dist/classes/liquid-drop.d.ts.map +1 -0
  28. package/dist/classes/liquid-drop.js +70 -0
  29. package/dist/classes/liquid-drop.js.map +1 -0
  30. package/dist/classes/liquid-engine.d.ts +7 -0
  31. package/dist/classes/liquid-engine.d.ts.map +1 -0
  32. package/dist/classes/liquid-engine.js +72 -0
  33. package/dist/classes/liquid-engine.js.map +1 -0
  34. package/dist/classes/package.d.ts +31 -0
  35. package/dist/classes/package.d.ts.map +1 -0
  36. package/dist/classes/package.js +268 -0
  37. package/dist/classes/package.js.map +1 -0
  38. package/dist/classes/platform-detector.d.ts +14 -0
  39. package/dist/classes/platform-detector.d.ts.map +1 -0
  40. package/dist/classes/platform-detector.js +26 -0
  41. package/dist/classes/platform-detector.js.map +1 -0
  42. package/dist/classes/policies.d.ts +14 -0
  43. package/dist/classes/policies.d.ts.map +1 -0
  44. package/dist/classes/policies.js +20 -0
  45. package/dist/classes/policies.js.map +1 -0
  46. package/dist/classes/template-expander.d.ts +29 -0
  47. package/dist/classes/template-expander.d.ts.map +1 -0
  48. package/dist/classes/template-expander.js +62 -0
  49. package/dist/classes/template-expander.js.map +1 -0
  50. package/dist/data/substitutions-variables.d.ts +43 -0
  51. package/dist/data/substitutions-variables.d.ts.map +1 -0
  52. package/dist/{lib → data}/substitutions-variables.js +1 -16
  53. package/dist/data/substitutions-variables.js.map +1 -0
  54. package/dist/functions/chmod-recursively.d.ts +9 -0
  55. package/dist/functions/chmod-recursively.d.ts.map +1 -0
  56. package/dist/functions/chmod-recursively.js +66 -0
  57. package/dist/functions/chmod-recursively.js.map +1 -0
  58. package/dist/functions/filter-paths.d.ts +5 -0
  59. package/dist/functions/filter-paths.d.ts.map +1 -0
  60. package/dist/functions/filter-paths.js +16 -0
  61. package/dist/functions/filter-paths.js.map +1 -0
  62. package/dist/functions/is-something.d.ts +9 -0
  63. package/dist/functions/is-something.d.ts.map +1 -0
  64. package/dist/functions/is-something.js +25 -0
  65. package/dist/functions/is-something.js.map +1 -0
  66. package/dist/functions/matrix-expander.d.ts +17 -0
  67. package/dist/functions/matrix-expander.d.ts.map +1 -0
  68. package/dist/functions/matrix-expander.js +52 -0
  69. package/dist/functions/matrix-expander.js.map +1 -0
  70. package/dist/functions/perform-substitutions.d.ts +12 -0
  71. package/dist/functions/perform-substitutions.d.ts.map +1 -0
  72. package/dist/functions/perform-substitutions.js +76 -0
  73. package/dist/functions/perform-substitutions.js.map +1 -0
  74. package/dist/functions/utils.d.ts +8 -0
  75. package/dist/functions/utils.d.ts.map +1 -0
  76. package/dist/functions/utils.js +16 -0
  77. package/dist/functions/utils.js.map +1 -0
  78. package/dist/index.d.ts +22 -15
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +22 -29
  81. package/dist/index.js.map +1 -1
  82. package/dist/{lib/types.d.ts → types/json.d.ts} +31 -22
  83. package/dist/types/json.d.ts.map +1 -0
  84. package/dist/types/json.js +2 -0
  85. package/dist/types/json.js.map +1 -0
  86. package/dist/types/xpm-init-template.d.ts +21 -0
  87. package/dist/types/xpm-init-template.d.ts.map +1 -0
  88. package/dist/types/xpm-init-template.js +2 -0
  89. package/dist/types/xpm-init-template.js.map +1 -0
  90. package/dist/types/xpm.d.ts +16 -0
  91. package/dist/types/xpm.d.ts.map +1 -0
  92. package/dist/types/xpm.js +2 -0
  93. package/dist/types/xpm.js.map +1 -0
  94. package/package.json +53 -44
  95. package/src/CODE-REVIEW.md +2167 -0
  96. package/src/README.md +393 -6
  97. package/src/classes/actions.ts +1157 -0
  98. package/src/classes/build-configurations.ts +2127 -0
  99. package/src/classes/combinations-generator.ts +331 -0
  100. package/src/classes/data-model.ts +337 -0
  101. package/src/classes/errors.ts +105 -0
  102. package/src/classes/init-template-base.ts +1028 -0
  103. package/src/classes/liquid-drop.ts +376 -0
  104. package/src/classes/liquid-engine.ts +249 -0
  105. package/src/classes/package.ts +765 -0
  106. package/src/classes/platform-detector.ts +237 -0
  107. package/src/classes/policies.ts +200 -0
  108. package/src/classes/template-expander.ts +330 -0
  109. package/src/data/substitutions-variables.ts +390 -0
  110. package/src/functions/chmod-recursively.ts +195 -0
  111. package/src/functions/filter-paths.ts +126 -0
  112. package/src/functions/is-something.ts +223 -0
  113. package/src/functions/matrix-expander.ts +172 -0
  114. package/src/functions/perform-substitutions.ts +253 -0
  115. package/src/functions/utils.ts +151 -0
  116. package/src/index.ts +72 -19
  117. package/src/types/json.ts +519 -0
  118. package/src/types/xpm-init-template.ts +282 -0
  119. package/src/types/xpm.ts +162 -0
  120. package/dist/lib/chmod-recursive.d.ts +0 -7
  121. package/dist/lib/chmod-recursive.d.ts.map +0 -1
  122. package/dist/lib/chmod-recursive.js +0 -81
  123. package/dist/lib/chmod-recursive.js.map +0 -1
  124. package/dist/lib/errors.d.ts +0 -11
  125. package/dist/lib/errors.d.ts.map +0 -1
  126. package/dist/lib/errors.js +0 -26
  127. package/dist/lib/errors.js.map +0 -1
  128. package/dist/lib/functions/chmod-recursive.d.ts +0 -7
  129. package/dist/lib/functions/chmod-recursive.d.ts.map +0 -1
  130. package/dist/lib/functions/chmod-recursive.js +0 -81
  131. package/dist/lib/functions/chmod-recursive.js.map +0 -1
  132. package/dist/lib/functions/perform-substitutions.d.ts +0 -20
  133. package/dist/lib/functions/perform-substitutions.d.ts.map +0 -1
  134. package/dist/lib/functions/perform-substitutions.js +0 -85
  135. package/dist/lib/functions/perform-substitutions.js.map +0 -1
  136. package/dist/lib/functions/utils.d.ts +0 -30
  137. package/dist/lib/functions/utils.d.ts.map +0 -1
  138. package/dist/lib/functions/utils.js +0 -70
  139. package/dist/lib/functions/utils.js.map +0 -1
  140. package/dist/lib/init-template-base.d.ts +0 -46
  141. package/dist/lib/init-template-base.d.ts.map +0 -1
  142. package/dist/lib/init-template-base.js +0 -281
  143. package/dist/lib/init-template-base.js.map +0 -1
  144. package/dist/lib/liquid-actions.d.ts +0 -37
  145. package/dist/lib/liquid-actions.d.ts.map +0 -1
  146. package/dist/lib/liquid-actions.js +0 -148
  147. package/dist/lib/liquid-actions.js.map +0 -1
  148. package/dist/lib/liquid-build-configurations.d.ts +0 -47
  149. package/dist/lib/liquid-build-configurations.d.ts.map +0 -1
  150. package/dist/lib/liquid-build-configurations.js +0 -282
  151. package/dist/lib/liquid-build-configurations.js.map +0 -1
  152. package/dist/lib/liquid-drop.d.ts +0 -13
  153. package/dist/lib/liquid-drop.d.ts.map +0 -1
  154. package/dist/lib/liquid-drop.js +0 -56
  155. package/dist/lib/liquid-drop.js.map +0 -1
  156. package/dist/lib/liquid-engine.d.ts +0 -5
  157. package/dist/lib/liquid-engine.d.ts.map +0 -1
  158. package/dist/lib/liquid-engine.js +0 -85
  159. package/dist/lib/liquid-engine.js.map +0 -1
  160. package/dist/lib/liquid-package.d.ts +0 -17
  161. package/dist/lib/liquid-package.d.ts.map +0 -1
  162. package/dist/lib/liquid-package.js +0 -70
  163. package/dist/lib/liquid-package.js.map +0 -1
  164. package/dist/lib/package.d.ts +0 -66
  165. package/dist/lib/package.d.ts.map +0 -1
  166. package/dist/lib/package.js +0 -700
  167. package/dist/lib/package.js.map +0 -1
  168. package/dist/lib/perform-substitutions.d.ts +0 -20
  169. package/dist/lib/perform-substitutions.d.ts.map +0 -1
  170. package/dist/lib/perform-substitutions.js +0 -85
  171. package/dist/lib/perform-substitutions.js.map +0 -1
  172. package/dist/lib/policies.d.ts +0 -14
  173. package/dist/lib/policies.d.ts.map +0 -1
  174. package/dist/lib/policies.js +0 -33
  175. package/dist/lib/policies.js.map +0 -1
  176. package/dist/lib/substitutions-variables.d.ts +0 -117
  177. package/dist/lib/substitutions-variables.d.ts.map +0 -1
  178. package/dist/lib/substitutions-variables.js.map +0 -1
  179. package/dist/lib/types.d.ts.map +0 -1
  180. package/dist/lib/types.js +0 -13
  181. package/dist/lib/types.js.map +0 -1
  182. package/dist/lib/utils.d.ts +0 -30
  183. package/dist/lib/utils.d.ts.map +0 -1
  184. package/dist/lib/utils.js +0 -70
  185. package/dist/lib/utils.js.map +0 -1
  186. package/dist/tsconfig.tsbuildinfo +0 -1
  187. package/src/lib/errors.ts +0 -29
  188. package/src/lib/functions/chmod-recursive.ts +0 -103
  189. package/src/lib/functions/perform-substitutions.ts +0 -116
  190. package/src/lib/functions/utils.ts +0 -88
  191. package/src/lib/init-template-base.ts +0 -408
  192. package/src/lib/liquid-actions.ts +0 -223
  193. package/src/lib/liquid-build-configurations.ts +0 -433
  194. package/src/lib/liquid-drop.ts +0 -99
  195. package/src/lib/liquid-engine.ts +0 -135
  196. package/src/lib/liquid-package.ts +0 -108
  197. package/src/lib/package.ts +0 -947
  198. package/src/lib/policies.ts +0 -51
  199. package/src/lib/substitutions-variables.ts +0 -177
  200. package/src/lib/types.ts +0 -109
  201. package/src/package.json +0 -3
  202. package/src/tsconfig.json +0 -10
@@ -0,0 +1,1028 @@
1
+ /*
2
+ * This file is part of the xPack project (http://xpack.github.io).
3
+ * Copyright (c) 2021-2026 Liviu Ionescu. All rights reserved.
4
+ *
5
+ * Permission to use, copy, modify, and/or distribute this software
6
+ * for any purpose is hereby granted, under the terms of the MIT license.
7
+ *
8
+ * If a copy of the license was not distributed with this file, it can
9
+ * be obtained from https://opensource.org/license/mit.
10
+ */
11
+
12
+ // ----------------------------------------------------------------------------
13
+
14
+ import assert from 'node:assert'
15
+ import * as util from 'node:util'
16
+ import * as readline from 'node:readline/promises'
17
+ import * as path from 'node:path'
18
+ import * as fs from 'node:fs/promises'
19
+
20
+ // https://www.npmjs.com/package/liquidjs
21
+ import { Liquid } from 'liquidjs'
22
+
23
+ import { Logger } from '@xpack/logger'
24
+
25
+ // ----------------------------------------------------------------------------
26
+
27
+ import {
28
+ InitTemplateItemValue,
29
+ InitTemplatePropertiesDefinitions,
30
+ InitTemplateSubstitutionsVariables,
31
+ } from '../types/xpm-init-template.js'
32
+ import { Context } from '../types/xpm.js'
33
+ import {
34
+ isString,
35
+ isObject,
36
+ isBoolean,
37
+ isNumber,
38
+ } from '../functions/is-something.js'
39
+ import {
40
+ JsonSyntaxError,
41
+ InputError,
42
+ OutputError,
43
+ ConfigurationError,
44
+ } from './errors.js'
45
+
46
+ // ============================================================================
47
+
48
+ /**
49
+ * Configuration parameters for constructing an `xpm init` template.
50
+ *
51
+ * @remarks
52
+ * This interface defines the required configuration for creating an
53
+ * instance of {@link InitTemplateBase} or its derived classes. All
54
+ * properties are mandatory except for the optional `process` parameter,
55
+ * parameter, which defaults to the global Node.js `process` object when
56
+ * not specified.
57
+ *
58
+ * The parameters provide the template with access to the <b>xpm</b>
59
+ * context, file system paths, property definitions, and the process
60
+ * environment necessary for template operations.
61
+ */
62
+ export interface InitTemplateConstructorParameters {
63
+ /**
64
+ * The <b>xpm</b> context containing configuration and logging utilities.
65
+ */
66
+ context: Context
67
+
68
+ /**
69
+ * The absolute path to the module folder.
70
+ */
71
+ __dirname: string
72
+
73
+ /**
74
+ * The absolute path to the templates folder.
75
+ */
76
+ templatesPath: string
77
+
78
+ /**
79
+ * Definitions of all properties supported by this template.
80
+ */
81
+ propertiesDefinitions: InitTemplatePropertiesDefinitions
82
+
83
+ /**
84
+ * The Node.js process object (defaults to the global `process`).
85
+ * Intended for testing purposes to allow mocking of process properties
86
+ * and methods.
87
+ */
88
+ process?: NodeJS.Process
89
+ }
90
+
91
+ /**
92
+ * Base class for `xpm init` templates.
93
+ *
94
+ * @remarks
95
+ * This abstract class provides the foundation for template-based project
96
+ * initialisation. It handles the complete workflow: property validation,
97
+ * interactive user prompts for missing mandatory values, variable
98
+ * substitution, and file generation using the Liquid templating engine.
99
+ *
100
+ * Template workflow:
101
+ *
102
+ * <ol>
103
+ * <li>Properties are validated against their definitions</li>
104
+ * <li>Missing mandatory properties trigger interactive prompts (if TTY)</li>
105
+ * <li>Substitution variables are prepared from properties</li>
106
+ * <li>The <code>InitTemplateBase.generate()</code> method creates project
107
+ * files</li>
108
+ * </ol>
109
+ *
110
+ * Derived classes must implement {@link InitTemplateBase.generate}
111
+ * to define the specific files and folder structure to create.
112
+ */
113
+ export abstract class InitTemplateBase {
114
+ // --------------------------------------------------------------------------
115
+ // Public Members.
116
+
117
+ // --------------------------------------------------------------------------
118
+ // Protected Members.
119
+
120
+ /**
121
+ * The <b>xpm</b> context containing configuration and logging utilities.
122
+ */
123
+ protected readonly _context: Context
124
+
125
+ /**
126
+ * The logger instance for output and diagnostics.
127
+ */
128
+ protected readonly _log: Logger
129
+
130
+ /**
131
+ * Definitions of all properties supported by this template.
132
+ */
133
+ protected readonly _propertiesDefinitions: InitTemplatePropertiesDefinitions =
134
+ {}
135
+
136
+ /**
137
+ * The absolute path to the module folder.
138
+ */
139
+ protected readonly __dirname: string
140
+
141
+ /**
142
+ * The absolute path to the templates folder.
143
+ */
144
+ protected readonly _templatesPath: string
145
+
146
+ /**
147
+ * The Liquid templating engine instance.
148
+ */
149
+ protected readonly _engine: Liquid
150
+
151
+ /**
152
+ * The variables to be used for template substitutions.
153
+ */
154
+ protected _substitutionsVariables?: InitTemplateSubstitutionsVariables
155
+
156
+ /**
157
+ * Flag indicating whether the template is running in interactive mode.
158
+ *
159
+ * @remarks
160
+ * This flag determines whether the template execution involved user
161
+ * interaction through terminal prompts for missing mandatory property
162
+ * values.
163
+ *
164
+ * State management:
165
+ *
166
+ * <ol>
167
+ * <li>Initialised to <code>false</code> upon construction.</li>
168
+ * <li>Set to <code>true</code> in {@link InitTemplateBase.run} if at least
169
+ * one mandatory property was missing and required interactive
170
+ * prompting.</li>
171
+ * <li>Set to <code>false</code> if all mandatory properties were provided
172
+ * via command-line options.</li>
173
+ * </ol>
174
+ *
175
+ * When interactive mode is activated, the context start time is reset
176
+ * after user input to exclude interactive time from performance metrics,
177
+ * ensuring accurate measurement of the template processing duration.
178
+ */
179
+ protected _isInteractive = false
180
+
181
+ /**
182
+ * The Node.js process object for accessing runtime environment information.
183
+ *
184
+ * @remarks
185
+ * This reference provides access to process properties including standard
186
+ * I/O streams, platform information, and architecture details. It is
187
+ * configurable via the constructor to support testing scenarios where
188
+ * process properties need to be mocked or controlled.
189
+ *
190
+ * Usage within the template:
191
+ *
192
+ * <ol>
193
+ * <li>Platform detection via <code>process.platform</code> and
194
+ * <code>process.arch</code> for
195
+ * platform-specific property validation.</li>
196
+ * <li>TTY detection via <code>stdin.isTTY</code> and
197
+ * <code>stdout.isTTY</code> to determine
198
+ * whether interactive prompting is possible.</li>
199
+ * <li>Standard I/O access for interactive user prompts and diagnostic
200
+ * output.</li>
201
+ * </ol>
202
+ *
203
+ * Defaults to the global Node.js <code>process</code> object when not
204
+ * explicitly provided in the constructor, enabling normal runtime
205
+ * behaviour whilst allowing test environments to inject controlled
206
+ * process implementations.
207
+ */
208
+ protected readonly _process: NodeJS.Process
209
+
210
+ // --------------------------------------------------------------------------
211
+ // Constructor.
212
+
213
+ /**
214
+ * Constructs an `xpm init` template instance.
215
+ *
216
+ * @param context - The <b>xpm</b> context containing configuration and
217
+ * logging.
218
+ * @param __dirname - The absolute path to the module folder.
219
+ * @param templatesPath - The absolute path to the templates folder.
220
+ * @param propertiesDefinitions - The definitions of all supported properties.
221
+ */
222
+ constructor({
223
+ context,
224
+ __dirname,
225
+ templatesPath,
226
+ propertiesDefinitions,
227
+ process: _process = process,
228
+ }: InitTemplateConstructorParameters) {
229
+ assert(context, 'context is required')
230
+ assert(context.log, 'context.log is required')
231
+ assert(context.config, 'context.context is required')
232
+ assert(context.config.projectName, 'context.config.projectName is required')
233
+ assert(context.config.properties, 'context.config.properties is required')
234
+ assert(__dirname, '__dirname is required')
235
+ assert(templatesPath, 'templatesPath is required')
236
+ assert(propertiesDefinitions, 'propertiesDefinitions is required')
237
+
238
+ this._context = context
239
+ this._log = context.log
240
+
241
+ this._propertiesDefinitions = propertiesDefinitions
242
+ this.__dirname = __dirname
243
+ this._templatesPath = templatesPath
244
+
245
+ this._process = _process
246
+
247
+ this._validatePropertiesDefinitions()
248
+
249
+ // https://liquidjs.com
250
+ this._engine = new Liquid({
251
+ root: this._templatesPath,
252
+ cache: false,
253
+ strictFilters: true, // default: false
254
+ strictVariables: true, // default: false
255
+ trimTagRight: false, // default: false
256
+ trimTagLeft: false, // default: false
257
+ greedy: false,
258
+ })
259
+ }
260
+
261
+ // --------------------------------------------------------------------------
262
+ // Public Methods.
263
+
264
+ /**
265
+ * Executes the template initialisation process.
266
+ *
267
+ * @remarks
268
+ * This method orchestrates the complete template initialisation workflow.
269
+ * It validates all provided properties, determines whether interactive
270
+ * mode is required (when mandatory properties are missing), prompts for
271
+ * missing values if in a TTY environment, prepares substitution variables
272
+ * including the current year, and invokes the template-specific
273
+ * {@link InitTemplateBase.generate} method to create project files.
274
+ *
275
+ * The method automatically applies default values to optional properties
276
+ * that were not explicitly set. In interactive mode, the timer is reset
277
+ * after user input to exclude interactive time from performance metrics.
278
+ *
279
+ * @returns A promise that resolves to 0 on success.
280
+ *
281
+ * @throws {@link JsonSyntaxError}
282
+ * If property validation fails or interactive mode is required but not
283
+ * available (non-TTY environment).
284
+ */
285
+ async run(): Promise<number> {
286
+ const log = this._log
287
+ log.trace(`${this.constructor.name}.run()`)
288
+
289
+ log.info()
290
+
291
+ const context = this._context
292
+ const config = context.config
293
+
294
+ assert(config.properties, 'config.properties is required')
295
+
296
+ const validationErrors: string[] = []
297
+ for (const [key, val] of Object.entries(config.properties)) {
298
+ try {
299
+ config.properties[key] = this._validatePropertyValue(key, val as string)
300
+ } catch (error) {
301
+ if (error instanceof Error) {
302
+ const errorMessage = `${key}: ${error.message}`
303
+ log.error(errorMessage)
304
+ validationErrors.push(errorMessage)
305
+ }
306
+ }
307
+ }
308
+ if (validationErrors.length > 0) {
309
+ throw new JsonSyntaxError(
310
+ validationErrors.length === 1
311
+ ? '1 invalid property'
312
+ : `${String(validationErrors.length)} invalid properties`
313
+ )
314
+ }
315
+
316
+ // Properties set by `--property name=value` are in `config.properties`.
317
+
318
+ // If there is at least one mandatory property without an explicit value,
319
+ // enter the interactive mode and ask for the missing values.
320
+
321
+ const mustAsk = Object.keys(this._propertiesDefinitions).some((key) => {
322
+ return (
323
+ this._propertiesDefinitions[key].isMandatory &&
324
+ !config.properties?.[key]
325
+ )
326
+ })
327
+
328
+ let isInteractive
329
+ if (mustAsk) {
330
+ // Need to ask for more values.
331
+ if (!(this._process.stdin.isTTY && this._process.stdout.isTTY)) {
332
+ throw new JsonSyntaxError(
333
+ 'Interactive mode not possible without a TTY.'
334
+ )
335
+ }
336
+
337
+ await this._askForMoreValues()
338
+ log.trace(util.inspect(config.properties))
339
+
340
+ // Reset start time to skip interactive time.
341
+ context.startTime = Date.now()
342
+ isInteractive = true
343
+ } else {
344
+ // Properties without explicit values get their defaults.
345
+ Object.entries(this._propertiesDefinitions).forEach(([key, val]) => {
346
+ assert(config.properties, 'config.properties is required')
347
+ if (!config.properties[key] && val.default !== undefined) {
348
+ config.properties[key] = val.default
349
+ }
350
+ })
351
+ isInteractive = false
352
+ }
353
+
354
+ this._isInteractive = isInteractive
355
+
356
+ const currentTime = new Date()
357
+
358
+ const substitutionsVariables: InitTemplateSubstitutionsVariables = {
359
+ // Spread all config properties.
360
+ ...config.properties,
361
+ // Also pass the properties grouped.
362
+ properties: config.properties,
363
+ propertiesNames: Object.keys(config.properties),
364
+ projectName: config.projectName,
365
+ year: currentTime.getFullYear().toString(),
366
+ }
367
+
368
+ this._substitutionsVariables = substitutionsVariables
369
+ await this.generate()
370
+
371
+ return 0 // success
372
+ }
373
+
374
+ /**
375
+ * Generates the project files from the template.
376
+ *
377
+ * @remarks
378
+ * This abstract method must be implemented by derived classes to define
379
+ * the specific files and folder structure to create for the project.
380
+ * Implementations should use {@link InitTemplateBase.copyFile},
381
+ * {@link InitTemplateBase.copyFolder}, and
382
+ * {@link InitTemplateBase.render} to create the project structure.
383
+ * The substitution variables are available via the
384
+ * {@link InitTemplateBase._substitutionsVariables} property.
385
+ *
386
+ * The implementation must be <b>asynchronous</b> to allow for file system
387
+ * operations.
388
+ *
389
+ * @returns A promise that resolves when generation is complete.
390
+ */
391
+ abstract /* async */ generate(): Promise<void>
392
+
393
+ /**
394
+ * Determines whether the current platform is supported.
395
+ *
396
+ * @remarks
397
+ * This method checks platform compatibility using a two-tier matching
398
+ * strategy. First, it looks for an exact match with the current
399
+ * platform-architecture combination (e.g., `darwin-arm64`). If not
400
+ * found, it checks for a platform-only match (e.g., `darwin`). Returns
401
+ * `false` if the platforms array is undefined, empty, or contains no
402
+ * matches for the current execution environment.
403
+ *
404
+ * @param platforms - The array of supported platform identifiers, or
405
+ * undefined if no platforms are specified.
406
+ * @returns `true` if the current platform is supported, `false`
407
+ * otherwise.
408
+ */
409
+ isPlatformSupported(platforms: string[] | undefined): boolean {
410
+ assert(platforms && platforms.length !== 0, 'platforms array is required')
411
+
412
+ if (platforms.includes(`${this._process.platform}-${this._process.arch}`)) {
413
+ return true
414
+ }
415
+
416
+ if (platforms.includes(this._process.platform)) {
417
+ return true
418
+ }
419
+
420
+ return false
421
+ }
422
+
423
+ /**
424
+ * Copies a single file from the templates folder to the destination.
425
+ *
426
+ * @remarks
427
+ * This method resolves the source file path relative to the templates
428
+ * folder and copies it to the destination, creating any necessary
429
+ * parent directories. The file is copied without modifications,
430
+ * preserving its content and structure. Use
431
+ * {@link InitTemplateBase.render} instead if variable substitution
432
+ * is needed.
433
+ *
434
+ * @param sourceFileRelativePath - The relative path to the source file
435
+ * within the templates folder.
436
+ * @param destinationFilePath - The destination file path (defaults to
437
+ * the same relative path as the source).
438
+ * @returns A promise that resolves when the file has been copied.
439
+ */
440
+ async copyFile({
441
+ sourceFileRelativePath,
442
+ destinationFilePath = sourceFileRelativePath,
443
+ }: {
444
+ sourceFileRelativePath: string
445
+ destinationFilePath?: string
446
+ }): Promise<void> {
447
+ const log = this._log
448
+
449
+ await fs.mkdir(path.dirname(destinationFilePath), { recursive: true })
450
+
451
+ const sourceFileAbsolutePath = path.resolve(
452
+ this._templatesPath,
453
+ sourceFileRelativePath
454
+ )
455
+ await fs.copyFile(sourceFileAbsolutePath, destinationFilePath)
456
+
457
+ const destinationFileRelativePath = path.relative(
458
+ this._context.config.cwd,
459
+ destinationFilePath
460
+ )
461
+ log.info(`File '${destinationFileRelativePath}' copied.`)
462
+ }
463
+
464
+ /**
465
+ * Copies an entire folder from the templates folder to the destination.
466
+ *
467
+ * @remarks
468
+ * This method recursively copies the complete folder structure,
469
+ * including all files and subfolders, from the source to the
470
+ * destination. The entire folder tree is replicated, preserving the
471
+ * relative paths and structure. Files are copied without
472
+ * modifications; use {@link InitTemplateBase.render} for
473
+ * individual files that require variable substitution.
474
+ *
475
+ * @param sourceFolderRelativePath - The relative path to the source folder
476
+ * within the templates folder.
477
+ * @param destinationFolderPath - The destination folder path (defaults to the
478
+ * same relative path as the source).
479
+ * @returns A promise that resolves when the folder has been copied.
480
+ */
481
+ async copyFolder({
482
+ sourceFolderRelativePath,
483
+ destinationFolderPath = sourceFolderRelativePath,
484
+ }: {
485
+ sourceFolderRelativePath: string
486
+ destinationFolderPath?: string
487
+ }): Promise<void> {
488
+ const log = this._log
489
+
490
+ const sourceFolderAbsolutePath = path.resolve(
491
+ this._templatesPath,
492
+ sourceFolderRelativePath
493
+ )
494
+ await this._copyFolderRecursively({
495
+ sourceFolderPath: sourceFolderAbsolutePath,
496
+ destinationFolderPath: path.resolve(destinationFolderPath),
497
+ })
498
+ log.info(`Folder '${destinationFolderPath}' copied.`)
499
+ }
500
+
501
+ /**
502
+ * Renders a template file using Liquid and writes the output.
503
+ *
504
+ * @remarks
505
+ * This method processes a template file through the Liquid templating
506
+ * engine with the provided substitution variables, generating the final
507
+ * output file. Parent directories are created automatically if they do
508
+ * not exist. The template file should be located in the templates
509
+ * folder and use Liquid syntax for variable references (e.g.,
510
+ * `{{ variableName }}`).
511
+ *
512
+ * The substitution variables include all project properties plus
513
+ * additional context like the current year. If substitutionsVariables
514
+ * is not provided, the instance's substitutionsVariables property is
515
+ * used.
516
+ *
517
+ * @param sourceFilePath - The absolute path to the template
518
+ * file within the templates folder.
519
+ * @param destinationFilePath - The destination path for the rendered
520
+ * file.
521
+ * @param substitutionsVariables - The variables to use for template
522
+ * substitutions (defaults to the instance's substitutionsVariables).
523
+ * @returns A promise that resolves when the file has been rendered and
524
+ * written.
525
+ *
526
+ * @throws {@link OutputError}
527
+ * If template rendering fails.
528
+ */
529
+ async render({
530
+ sourceFilePath,
531
+ destinationFilePath,
532
+ substitutionsVariables = this._substitutionsVariables,
533
+ }: {
534
+ sourceFilePath: string
535
+ destinationFilePath: string
536
+ substitutionsVariables?: InitTemplateSubstitutionsVariables
537
+ }): Promise<void> {
538
+ assert(
539
+ substitutionsVariables !== undefined,
540
+ 'substitutionsVariables is required for rendering templates. ' +
541
+ 'Ensure that run() has been called to prepare the variables.'
542
+ )
543
+
544
+ const log = this._log
545
+ const context = this._context
546
+ const config = context.config
547
+ const cwd = config.cwd
548
+
549
+ const sourceFileRelativePath = path.relative(cwd, sourceFilePath)
550
+ const destinationFileRelativePath = path.relative(cwd, destinationFilePath)
551
+
552
+ log.info(
553
+ `Rendering template '${sourceFileRelativePath}' to ` +
554
+ `'${destinationFileRelativePath}'`
555
+ )
556
+
557
+ log.trace(`render(${sourceFilePath}, ${destinationFilePath})`)
558
+
559
+ // const headerPath = path.resolve(codePath, `${pnam}.h`)
560
+ try {
561
+ const fileContent = (await this._engine.renderFile(
562
+ sourceFilePath,
563
+ substitutionsVariables
564
+ )) as string
565
+
566
+ await fs.mkdir(path.dirname(destinationFilePath), { recursive: true })
567
+ await fs.writeFile(destinationFilePath, fileContent, 'utf8')
568
+ } catch (error) {
569
+ if (error instanceof Error) {
570
+ throw new OutputError(error.message)
571
+ }
572
+ }
573
+ log.info(`File '${destinationFileRelativePath}' generated.`)
574
+ }
575
+
576
+ // --------------------------------------------------------------------------
577
+ // Protected Methods.
578
+
579
+ /**
580
+ * Validates a property value against its definition.
581
+ *
582
+ * @remarks
583
+ * This method checks whether the provided value is valid for the
584
+ * specified property according to its type definition. It performs
585
+ * type-specific validation and conversion:
586
+ *
587
+ * <ul>
588
+ * <li><b>For <code>select</code> properties:</b> validates against
589
+ * allowed items andchecks platform compatibility if specified</li>
590
+ * <li><b>For <code>boolean</code> properties:</b> converts
591
+ * <code>'true'</code>/<code>'false'</code>
592
+ * strings to booleans</li>
593
+ * <li><b>For <code>number</code> properties:</b> converts strings
594
+ * to numbers</li>
595
+ * </ul>
596
+ *
597
+ * If the value is empty and a default is defined, the default value is
598
+ * returned. For select properties with platform restrictions, only
599
+ * platform-compatible items are considered valid.
600
+ *
601
+ * @param name - The property name to validate.
602
+ * @param value - The property value to validate.
603
+ * @returns The validated and potentially converted value (string,
604
+ * boolean, or number).
605
+ *
606
+ * @throws {@link ConfigurationError}
607
+ * If the property is unsupported or the value is invalid.
608
+ */
609
+ protected _validatePropertyValue(
610
+ name: string,
611
+ value: string
612
+ ): string | boolean | number {
613
+ const propDef = this._propertiesDefinitions[name]
614
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
615
+ if (propDef === undefined) {
616
+ throw new ConfigurationError(`Unsupported property '${name}'`)
617
+ }
618
+ const trimmedValue = value.trim()
619
+
620
+ if (trimmedValue === '') {
621
+ if (propDef.default !== undefined) {
622
+ return propDef.default
623
+ }
624
+ } else {
625
+ switch (propDef.type) {
626
+ case 'select':
627
+ assert(
628
+ propDef.items,
629
+ `Property '${name}' of type 'select' has no items.`
630
+ )
631
+ if (propDef.items[value]) {
632
+ if (typeof propDef.items[value] === 'string') {
633
+ return value
634
+ } else if (
635
+ typeof propDef.items[value] === 'object' &&
636
+ this.isPlatformSupported(propDef.items[value].platforms)
637
+ ) {
638
+ return value
639
+ }
640
+ }
641
+ break
642
+
643
+ case 'boolean':
644
+ if (trimmedValue === 'true') {
645
+ return true
646
+ } else if (trimmedValue === 'false') {
647
+ return false
648
+ }
649
+ break
650
+
651
+ case 'number': {
652
+ const num = Number(trimmedValue)
653
+ if (isFinite(num)) {
654
+ return num
655
+ }
656
+ break
657
+ }
658
+
659
+ case 'string':
660
+ return value
661
+
662
+ // No default, the definition was already validated.
663
+ }
664
+ }
665
+
666
+ throw new ConfigurationError(
667
+ `Unsupported value '${value}' for property '${name}'`
668
+ )
669
+ }
670
+
671
+ /**
672
+ * Prompts the user interactively for missing property values.
673
+ *
674
+ * @remarks
675
+ * This method creates a readline interface and iteratively prompts the
676
+ * user to provide values for properties without explicit values. For
677
+ * each property, the prompt displays:
678
+ *
679
+ * <ul>
680
+ * <li>The property label</li>
681
+ * <li>Valid options (for select and boolean types)</li>
682
+ * <li>The default value in brackets, if available</li>
683
+ * </ul>
684
+ *
685
+ * If the user enters '?', help text is displayed showing the property
686
+ * description and all valid options with their descriptions. Invalid
687
+ * responses are rejected and the prompt is repeated until a valid value
688
+ * is provided. Platform-incompatible options are excluded from select
689
+ * properties.
690
+ *
691
+ * @returns A promise that resolves when all missing values have been
692
+ * collected.
693
+ */
694
+ protected async _askForMoreValues() {
695
+ const context = this._context
696
+ const config = context.config
697
+
698
+ assert(config.properties, 'config.properties is required')
699
+
700
+ const rl = readline.createInterface({
701
+ input: this._process.stdin,
702
+ output: this._process.stdout,
703
+ })
704
+
705
+ for (const name of Object.keys(this._propertiesDefinitions)) {
706
+ if (config.properties[name]) {
707
+ continue
708
+ }
709
+ const definition = this._propertiesDefinitions[name]
710
+ let prompt = `${definition.label}?`
711
+ switch (definition.type) {
712
+ case 'select': {
713
+ prompt += ' ('
714
+ const validItems = []
715
+ assert(definition.items, 'definition.items is required')
716
+ for (const [ikey, ival] of Object.entries(definition.items)) {
717
+ if (isString(ival)) {
718
+ validItems.push(ikey)
719
+ } else if (
720
+ isObject(ival) &&
721
+ this.isPlatformSupported(
722
+ (ival as InitTemplateItemValue).platforms
723
+ )
724
+ ) {
725
+ validItems.push(ikey)
726
+ }
727
+ }
728
+ prompt += validItems.join(', ')
729
+ prompt += ', ?)'
730
+ break
731
+ }
732
+ case 'string':
733
+ prompt += ' (string, ?)'
734
+ break
735
+ case 'number':
736
+ prompt += ' (number, ?)'
737
+ break
738
+ case 'boolean':
739
+ prompt += ' (true, false, ?)'
740
+ break
741
+ // No default, the definition was already validated.
742
+ }
743
+
744
+ if (definition.default !== undefined) {
745
+ prompt += ` [${String(definition.default)}]`
746
+ }
747
+ prompt += ': '
748
+
749
+ const MAX_RETRIES = 42
750
+ let retryCount = 0
751
+
752
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
753
+ while (true) {
754
+ /* c8 ignore start - Defensive check. */
755
+ if (++retryCount > MAX_RETRIES) {
756
+ throw new InputError(
757
+ `Too many invalid attempts for property '${name}' ` +
758
+ `(limit: ${String(MAX_RETRIES)})`
759
+ )
760
+ }
761
+ /* c8 ignore stop */
762
+
763
+ const answer = (await rl.question(prompt)).trim()
764
+ // console.log('{' + answer + '}')
765
+ try {
766
+ const value = this._validatePropertyValue(name, answer)
767
+ // console.log('[' + value + ']')
768
+ config.properties[name] = value
769
+ break
770
+ } catch (error) {
771
+ if (error instanceof Error) {
772
+ this._log.trace(error.message)
773
+ }
774
+ this._process.stdout.write(`${definition.description}\n`)
775
+ if (definition.type === 'select') {
776
+ assert(definition.items, 'definition.items is required')
777
+ for (const [ikey, ival] of Object.entries(definition.items)) {
778
+ if (isString(ival)) {
779
+ this._process.stdout.write(`- ${ikey}: ${ival as string}\n`)
780
+ } else if (
781
+ isObject(ival) &&
782
+ this.isPlatformSupported(
783
+ (ival as InitTemplateItemValue).platforms
784
+ )
785
+ ) {
786
+ this._process.stdout.write(
787
+ `- ${ikey}: ${(ival as InitTemplateItemValue).message}\n`
788
+ )
789
+ }
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Recursively copies all contents of a source folder to a destination folder.
799
+ *
800
+ * @remarks
801
+ * This internal method traverses the source folder structure and replicates
802
+ * it at the destination, copying all files and recursively processing
803
+ * subfolders.
804
+ *
805
+ * @param sourceFolderPath - The absolute path to the source folder.
806
+ * @param destinationFolderPath - The absolute path to the destination folder.
807
+ * @returns A promise that resolves when all contents have been copied.
808
+ */
809
+ protected async _copyFolderRecursively({
810
+ sourceFolderPath,
811
+ destinationFolderPath,
812
+ }: {
813
+ sourceFolderPath: string
814
+ destinationFolderPath: string
815
+ }): Promise<void> {
816
+ // const log = this.log
817
+
818
+ await fs.mkdir(destinationFolderPath, { recursive: true })
819
+
820
+ const dirents = await fs.readdir(sourceFolderPath, {
821
+ withFileTypes: true,
822
+ })
823
+
824
+ for (const dirent of dirents) {
825
+ // log.trace(dirent.name)
826
+
827
+ if (dirent.isDirectory()) {
828
+ await this._copyFolderRecursively({
829
+ sourceFolderPath: path.join(sourceFolderPath, dirent.name),
830
+ destinationFolderPath: path.join(destinationFolderPath, dirent.name),
831
+ })
832
+ } else {
833
+ await fs.copyFile(
834
+ path.join(sourceFolderPath, dirent.name),
835
+ path.join(destinationFolderPath, dirent.name)
836
+ )
837
+ }
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Validates the structure and content of property definitions.
843
+ *
844
+ * @remarks
845
+ * This internal method performs comprehensive validation of the property
846
+ * definitions object during template construction, ensuring all definitions
847
+ * are well-formed and internally consistent before the template is used.
848
+ *
849
+ * Validation steps:
850
+ *
851
+ * <ol>
852
+ * <li><b>Overall structure:</b>
853
+ * <ul>
854
+ * <li>Verifies that <code>propertiesDefinitions</code> is an object.</li>
855
+ * <li>Ensures at least one property is defined (not empty).</li>
856
+ * </ul>
857
+ * </li>
858
+ * <li><b>Common property fields:</b>
859
+ * <ul>
860
+ * <li><code>label</code>: Must be a non-empty string.</li>
861
+ * <li><code>description</code>: Must be a non-empty string.</li>
862
+ * <li><code>isMandatory</code>: Must be a boolean if present.</li>
863
+ * <li><code>type</code>: Must be defined and one of: <code>select</code>,
864
+ * <code>string</code>, <code>number</code>, <code>boolean</code>.</li>
865
+ * </ul>
866
+ * </li>
867
+ * <li><b>Type-specific validation:</b>
868
+ * <ul>
869
+ * <li><b>Select properties:</b>
870
+ * <ul>
871
+ * <li>Must have an <code>items</code> object with at least one
872
+ * entry.</li>
873
+ * <li>Each item must be either a string (description) or an object with
874
+ * <code>platforms</code> array and <code>message</code> string.</li>
875
+ * <li>Non-mandatory properties must have a default value.</li>
876
+ * <li>Default values must be non-empty strings present in the items
877
+ * list.</li>
878
+ * </ul>
879
+ * </li>
880
+ * <li><b>String properties:</b> Default value must be a non-empty string
881
+ * if present.</li>
882
+ * <li><b>Number properties:</b> Default value must be a number if
883
+ * present.</li>
884
+ * <li><b>Boolean properties:</b> Default value must be a boolean if
885
+ * present.</li>
886
+ * </ul>
887
+ * </li>
888
+ * </ol>
889
+ *
890
+ * This validation ensures that templates are correctly configured before
891
+ * use, preventing runtime errors during property processing and interactive
892
+ * prompting. Any validation failure triggers an assertion error with a
893
+ * descriptive message indicating the specific problem.
894
+ */
895
+ protected _validatePropertiesDefinitions(): void {
896
+ assert(
897
+ isObject(this._propertiesDefinitions),
898
+ 'propertiesDefinitions is not an object.'
899
+ )
900
+
901
+ assert(
902
+ Object.keys(this._propertiesDefinitions).length > 0,
903
+ 'propertiesDefinitions is an empty object.'
904
+ )
905
+
906
+ for (const [key, val] of Object.entries(this._propertiesDefinitions)) {
907
+ assert(isString(val.label), `Property '${key}' must have a string label`)
908
+ assert(val.label.trim() !== '', `Property '${key}' has an empty label`)
909
+
910
+ assert(
911
+ isString(val.description),
912
+ `Property '${key}' must have a string description`
913
+ )
914
+ assert(
915
+ val.description.trim() !== '',
916
+ `Property '${key}' has an empty description`
917
+ )
918
+
919
+ if (val.isMandatory !== undefined) {
920
+ assert(
921
+ isBoolean(val.isMandatory),
922
+ `Property '${key}' has a non boolean isMandatory value.`
923
+ )
924
+ }
925
+
926
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
927
+ assert(val.type !== undefined, `Property '${key}' has no type defined.`)
928
+
929
+ switch (val.type) {
930
+ case 'select':
931
+ assert(
932
+ val.items !== undefined,
933
+ `Property '${key}' of type 'select' has no items.`
934
+ )
935
+
936
+ assert(
937
+ isObject(val.items),
938
+ `Property '${key}' of type 'select' has invalid items.`
939
+ )
940
+
941
+ assert(
942
+ Object.keys(val.items).length !== 0,
943
+ `Property '${key}' of type 'select' has no items.`
944
+ )
945
+
946
+ for (const [ikey, ival] of Object.entries(val.items)) {
947
+ assert(
948
+ isString(ival) ||
949
+ (isObject(ival) &&
950
+ Array.isArray((ival as InitTemplateItemValue).platforms) &&
951
+ isString((ival as InitTemplateItemValue).message)),
952
+ `Property '${key}' has invalid item '${ikey}'.`
953
+ )
954
+ }
955
+
956
+ if (!val.isMandatory) {
957
+ assert(
958
+ val.default !== undefined,
959
+ `Property '${key}' of type 'select' ` +
960
+ `must have a default value if not mandatory.`
961
+ )
962
+ }
963
+
964
+ if (val.default !== undefined) {
965
+ assert(
966
+ isString(val.default),
967
+ `Property '${key}' has a non string default value.`
968
+ )
969
+
970
+ assert(
971
+ (val.default as string).trim() !== '',
972
+ `Property '${key}' has an empty default value.`
973
+ )
974
+ }
975
+
976
+ if (val.default !== undefined) {
977
+ assert(
978
+ Object.keys(val.items).includes(String(val.default)),
979
+ `Property '${key}' has a default value not in items list.`
980
+ )
981
+ }
982
+ break
983
+
984
+ case 'string':
985
+ if (val.default !== undefined) {
986
+ assert(
987
+ isString(val.default),
988
+ `Property '${key}' has a non string default value.`
989
+ )
990
+
991
+ assert(
992
+ (val.default as string).trim() !== '',
993
+ `Property '${key}' has an empty default value.`
994
+ )
995
+ }
996
+ break
997
+
998
+ case 'number':
999
+ if (val.default !== undefined) {
1000
+ assert(
1001
+ isNumber(val.default),
1002
+ `Property '${key}' has a non number default value.`
1003
+ )
1004
+ }
1005
+ break
1006
+
1007
+ case 'boolean':
1008
+ if (val.default !== undefined) {
1009
+ assert(
1010
+ isBoolean(val.default),
1011
+ `Property '${key}' has a non boolean default value.`
1012
+ )
1013
+ }
1014
+ break
1015
+
1016
+ default:
1017
+ assert(
1018
+ false,
1019
+ `Property '${key}' has unsupported type '${String(val.type)}'.`
1020
+ )
1021
+
1022
+ break
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ // ----------------------------------------------------------------------------