@toolstackhq/create-qa-patterns 1.0.14 → 1.0.15

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 (123) hide show
  1. package/README.md +5 -0
  2. package/index.js +252 -1076
  3. package/lib/args.js +139 -0
  4. package/lib/constants.js +115 -0
  5. package/lib/interactive.js +131 -0
  6. package/lib/local-env.js +65 -0
  7. package/lib/metadata.js +329 -0
  8. package/lib/output.js +326 -0
  9. package/lib/prereqs.js +72 -0
  10. package/lib/scaffold.js +120 -0
  11. package/lib/templates.js +40 -0
  12. package/package.json +5 -3
  13. package/templates/cypress-template/.env.example +2 -2
  14. package/templates/cypress-template/.github/workflows/cypress-tests.yml +2 -2
  15. package/templates/cypress-template/README.md +10 -6
  16. package/templates/cypress-template/allurerc.mjs +1 -1
  17. package/templates/cypress-template/config/environments.ts +13 -11
  18. package/templates/cypress-template/config/runtime-config.ts +17 -12
  19. package/templates/cypress-template/config/secret-manager.ts +1 -1
  20. package/templates/cypress-template/config/test-env.ts +3 -3
  21. package/templates/cypress-template/cypress/e2e/ui-journey.cy.ts +12 -10
  22. package/templates/cypress-template/cypress/support/app-config.ts +5 -5
  23. package/templates/cypress-template/cypress/support/commands.ts +7 -7
  24. package/templates/cypress-template/cypress/support/data/data-factory.ts +6 -4
  25. package/templates/cypress-template/cypress/support/data/id-generator.ts +1 -1
  26. package/templates/cypress-template/cypress/support/data/seeded-faker.ts +2 -2
  27. package/templates/cypress-template/cypress/support/e2e.ts +2 -2
  28. package/templates/cypress-template/cypress/support/pages/login-page.ts +4 -4
  29. package/templates/cypress-template/cypress/support/pages/people-page.ts +10 -10
  30. package/templates/cypress-template/cypress.config.ts +9 -9
  31. package/templates/cypress-template/demo-apps/ui-demo-app/public/styles.css +1 -1
  32. package/templates/cypress-template/demo-apps/ui-demo-app/src/server.js +44 -41
  33. package/templates/cypress-template/demo-apps/ui-demo-app/src/store.js +31 -3
  34. package/templates/cypress-template/demo-apps/ui-demo-app/src/templates.js +5 -5
  35. package/templates/cypress-template/eslint.config.mjs +53 -45
  36. package/templates/cypress-template/package.json +6 -5
  37. package/templates/cypress-template/scripts/ensure-local-env.mjs +36 -0
  38. package/templates/cypress-template/scripts/generate-allure-report.mjs +16 -10
  39. package/templates/cypress-template/scripts/run-cypress.mjs +33 -24
  40. package/templates/cypress-template/scripts/run-tests.sh +1 -0
  41. package/templates/cypress-template/tsconfig.json +7 -1
  42. package/templates/playwright-template/.env.example +6 -6
  43. package/templates/playwright-template/.github/workflows/playwright-tests.yml +14 -5
  44. package/templates/playwright-template/README.md +6 -5
  45. package/templates/playwright-template/allurerc.mjs +1 -1
  46. package/templates/playwright-template/components/flash-message.ts +2 -2
  47. package/templates/playwright-template/config/environments.ts +16 -14
  48. package/templates/playwright-template/config/runtime-config.ts +17 -12
  49. package/templates/playwright-template/config/secret-manager.ts +1 -1
  50. package/templates/playwright-template/config/test-env.ts +3 -3
  51. package/templates/playwright-template/data/factories/data-factory.ts +6 -4
  52. package/templates/playwright-template/data/generators/id-generator.ts +1 -1
  53. package/templates/playwright-template/data/generators/seeded-faker.ts +2 -2
  54. package/templates/playwright-template/demo-apps/api-demo-server/src/server.js +9 -9
  55. package/templates/playwright-template/demo-apps/api-demo-server/src/store.js +1 -1
  56. package/templates/playwright-template/demo-apps/ui-demo-app/public/styles.css +1 -1
  57. package/templates/playwright-template/demo-apps/ui-demo-app/src/server.js +44 -41
  58. package/templates/playwright-template/demo-apps/ui-demo-app/src/store.js +31 -3
  59. package/templates/playwright-template/demo-apps/ui-demo-app/src/templates.js +5 -5
  60. package/templates/playwright-template/eslint.config.mjs +40 -40
  61. package/templates/playwright-template/fixtures/test-fixtures.ts +27 -12
  62. package/templates/playwright-template/lint/architecture-plugin.cjs +36 -31
  63. package/templates/playwright-template/package.json +7 -6
  64. package/templates/playwright-template/pages/base-page.ts +4 -4
  65. package/templates/playwright-template/pages/login-page.ts +9 -9
  66. package/templates/playwright-template/pages/people-page.ts +21 -17
  67. package/templates/playwright-template/playwright.config.ts +22 -19
  68. package/templates/playwright-template/reporters/structured-reporter.ts +11 -8
  69. package/templates/playwright-template/scripts/ensure-local-env.mjs +37 -0
  70. package/templates/playwright-template/scripts/generate-allure-report.mjs +16 -10
  71. package/templates/playwright-template/scripts/run-tests.sh +1 -0
  72. package/templates/playwright-template/tests/api-people.spec.ts +8 -6
  73. package/templates/playwright-template/tests/ui-journey.spec.ts +13 -8
  74. package/templates/playwright-template/tsconfig.json +3 -11
  75. package/templates/playwright-template/utils/logger.ts +12 -8
  76. package/templates/playwright-template/utils/test-step.ts +5 -5
  77. package/templates/wdio-template/.env.example +14 -0
  78. package/templates/wdio-template/.github/workflows/wdio-tests.yml +46 -0
  79. package/templates/wdio-template/README.md +241 -0
  80. package/templates/wdio-template/allurerc.mjs +10 -0
  81. package/templates/wdio-template/components/README.md +5 -0
  82. package/templates/wdio-template/components/flash-message.ts +16 -0
  83. package/templates/wdio-template/config/README.md +5 -0
  84. package/templates/wdio-template/config/environments.ts +40 -0
  85. package/templates/wdio-template/config/runtime-config.ts +53 -0
  86. package/templates/wdio-template/config/secret-manager.ts +29 -0
  87. package/templates/wdio-template/config/test-env.ts +9 -0
  88. package/templates/wdio-template/data/README.md +9 -0
  89. package/templates/wdio-template/data/factories/README.md +6 -0
  90. package/templates/wdio-template/data/factories/data-factory.ts +36 -0
  91. package/templates/wdio-template/data/generators/README.md +5 -0
  92. package/templates/wdio-template/data/generators/id-generator.ts +18 -0
  93. package/templates/wdio-template/data/generators/seeded-faker.ts +14 -0
  94. package/templates/wdio-template/demo-apps/ui-demo-app/public/styles.css +120 -0
  95. package/templates/wdio-template/demo-apps/ui-demo-app/src/server.js +152 -0
  96. package/templates/wdio-template/demo-apps/ui-demo-app/src/store.js +71 -0
  97. package/templates/wdio-template/demo-apps/ui-demo-app/src/templates.js +121 -0
  98. package/templates/wdio-template/eslint.config.mjs +86 -0
  99. package/templates/wdio-template/lint/architecture-plugin.cjs +123 -0
  100. package/templates/wdio-template/package-lock.json +11058 -0
  101. package/templates/wdio-template/package.json +44 -0
  102. package/templates/wdio-template/pages/README.md +6 -0
  103. package/templates/wdio-template/pages/base-page.ts +15 -0
  104. package/templates/wdio-template/pages/login-page.ts +27 -0
  105. package/templates/wdio-template/pages/people-page.ts +54 -0
  106. package/templates/wdio-template/reporters/README.md +5 -0
  107. package/templates/wdio-template/reporters/structured-reporter.ts +78 -0
  108. package/templates/wdio-template/scripts/README.md +5 -0
  109. package/templates/wdio-template/scripts/ensure-local-env.mjs +36 -0
  110. package/templates/wdio-template/scripts/generate-allure-report.mjs +72 -0
  111. package/templates/wdio-template/scripts/run-tests.sh +7 -0
  112. package/templates/wdio-template/scripts/run-wdio.mjs +114 -0
  113. package/templates/wdio-template/tests/README.md +7 -0
  114. package/templates/wdio-template/tests/ui-journey.spec.ts +52 -0
  115. package/templates/wdio-template/tsconfig.json +22 -0
  116. package/templates/wdio-template/utils/README.md +5 -0
  117. package/templates/wdio-template/utils/logger.ts +60 -0
  118. package/templates/wdio-template/utils/test-step.ts +20 -0
  119. package/templates/wdio-template/wdio.conf.ts +58 -0
  120. package/tests/args.test.js +58 -0
  121. package/tests/local-env.test.js +70 -0
  122. package/tests/metadata.test.js +147 -0
  123. package/tests/templates.test.js +44 -0
package/index.js CHANGED
@@ -1,742 +1,131 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require("node:fs");
4
- const path = require("node:path");
5
- const readline = require("node:readline");
6
- const crypto = require("node:crypto");
7
- const { spawn, spawnSync } = require("node:child_process");
8
-
9
- const DEFAULT_TEMPLATE = "playwright-template";
10
- const CLI_PACKAGE = require("./package.json");
11
- const METADATA_FILENAME = ".qa-patterns.json";
12
- const MIN_NODE_VERSION = {
13
- major: 18,
14
- minor: 18,
15
- patch: 0
16
- };
17
- const COLOR_ENABLED = Boolean(process.stdout.isTTY) && !("NO_COLOR" in process.env);
18
- const DEFAULT_GITIGNORE = `node_modules/
19
-
20
- .env
21
- .env.*
22
- !.env.example
23
-
24
- .DS_Store
25
- *.log
26
- *.tgz
27
- .idea/
28
- .vscode/
29
- .nyc_output/
30
- coverage/
31
- dist/
32
- build/
33
- tmp/
34
- temp/
35
- downloads/
36
- cypress.env.json
37
- reports/
38
- cypress/screenshots/
39
- cypress/videos/
40
- reports/screenshots/
41
- reports/videos/
42
- allure-results/
43
- allure-report/
44
- test-results/
45
- playwright-report/
46
- `;
47
-
48
- const MANAGED_FILE_PATTERNS = {
49
- common: [
50
- ".env.example",
51
- ".gitignore",
52
- "package.json",
53
- "package-lock.json",
54
- "tsconfig.json",
55
- "eslint.config.mjs",
56
- "allurerc.mjs",
57
- "config/**",
58
- "scripts/**",
59
- ".github/**"
60
- ],
61
- "playwright-template": [
62
- "playwright.config.ts",
63
- "docker/**",
64
- "lint/**",
65
- "reporters/**",
66
- "utils/logger.ts",
67
- "utils/test-step.ts"
68
- ],
69
- "cypress-template": ["cypress.config.ts"]
70
- };
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const {
6
+ CLI_PACKAGE_VERSION,
7
+ DEFAULT_GITIGNORE,
8
+ DEFAULT_TEMPLATE,
9
+ MANAGED_FILE_PATTERNS,
10
+ METADATA_FILENAME,
11
+ MIN_NODE_VERSION,
12
+ TEMPLATE_CATALOG
13
+ } = require('./lib/constants');
14
+ const { parseCliOptions, resolveNonInteractiveArgs } = require('./lib/args');
15
+ const {
16
+ analyzeUpgrade,
17
+ applySafeUpdates,
18
+ detectTemplateFromProject,
19
+ readProjectMetadata,
20
+ renderTemplateFile,
21
+ writeProjectMetadata
22
+ } = require('./lib/metadata');
23
+ const {
24
+ createLocalCredentials,
25
+ writeGeneratedLocalEnv
26
+ } = require('./lib/local-env');
27
+ const {
28
+ askQuestion,
29
+ askYesNo,
30
+ selectTemplateInteractively
31
+ } = require('./lib/interactive');
32
+ const {
33
+ assertSupportedNodeVersion,
34
+ createColors,
35
+ printHelp,
36
+ printNextSteps,
37
+ printPlaywrightInstallRecovery,
38
+ printPrerequisiteWarnings,
39
+ printSuccess,
40
+ printSummary,
41
+ printUpgradeReport
42
+ } = require('./lib/output');
43
+ const {
44
+ collectPrerequisites,
45
+ initializeGitRepository,
46
+ runCommand
47
+ } = require('./lib/prereqs');
48
+ const { scaffoldProject } = require('./lib/scaffold');
49
+ const {
50
+ createTemplateAliases,
51
+ getTemplate,
52
+ getTemplateDirectory,
53
+ resolveTemplate,
54
+ toPackageName
55
+ } = require('./lib/templates');
56
+
57
+ const colors = createColors();
58
+ const TEMPLATES = TEMPLATE_CATALOG.map((template) => {
59
+ if (template.id !== 'playwright-template') {
60
+ return template;
61
+ }
71
62
 
72
- const TEMPLATES = [
73
- {
74
- id: DEFAULT_TEMPLATE,
75
- aliases: ["playwright", "pw"],
76
- label: "Playwright Template",
77
- description: "TypeScript starter with page objects, fixtures, multi-environment config, reporting, linting, CI and Docker.",
78
- defaultPackageName: "playwright-template",
79
- demoAppsManagedByTemplate: true,
63
+ return {
64
+ ...template,
80
65
  setup: {
81
- availability: "npx",
82
- prompt: "Run npx playwright install now?",
83
- summaryLabel: "Playwright browser install",
84
- nextStep: "npx playwright install",
66
+ ...template.setup,
85
67
  run(targetDirectory) {
86
- return runCommand("npx", ["playwright", "install"], targetDirectory);
68
+ return runCommand('npx', ['playwright', 'install'], targetDirectory);
87
69
  },
88
70
  recovery(targetDirectory) {
89
- printPlaywrightInstallRecovery(targetDirectory);
90
- }
91
- }
92
- },
93
- {
94
- id: "cypress-template",
95
- aliases: ["cypress", "cy"],
96
- label: "Cypress Template",
97
- description: "TypeScript starter with Cypress e2e specs, custom commands, page modules, env-based config, CI, and a bundled demo app.",
98
- defaultPackageName: "cypress-template",
99
- demoAppsManagedByTemplate: true
100
- }
101
- ];
102
-
103
- const TEMPLATE_ALIASES = new Map(
104
- TEMPLATES.flatMap((template) => [
105
- [template.id, template.id],
106
- ...template.aliases.map((alias) => [alias, template.id])
107
- ])
108
- );
109
-
110
- function style(text, ...codes) {
111
- if (!COLOR_ENABLED) {
112
- return text;
113
- }
114
-
115
- return `\u001b[${codes.join(";")}m${text}\u001b[0m`;
116
- }
117
-
118
- const colors = {
119
- bold(text) {
120
- return style(text, 1);
121
- },
122
- dim(text) {
123
- return style(text, 2);
124
- },
125
- cyan(text) {
126
- return style(text, 36);
127
- },
128
- green(text) {
129
- return style(text, 32);
130
- },
131
- yellow(text) {
132
- return style(text, 33);
133
- },
134
- red(text) {
135
- return style(text, 31);
136
- }
137
- };
138
-
139
- function sha256(content) {
140
- return crypto.createHash("sha256").update(content).digest("hex");
141
- }
142
-
143
- function normalizePath(value) {
144
- return value.split(path.sep).join("/");
145
- }
146
-
147
- function getTemplateDirectory(templateId) {
148
- return path.resolve(__dirname, "templates", templateId);
149
- }
150
-
151
- function pathMatchesPattern(relativePath, pattern) {
152
- if (pattern.endsWith("/**")) {
153
- const prefix = pattern.slice(0, -3);
154
- return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
155
- }
156
-
157
- return relativePath === pattern;
158
- }
159
-
160
- function isManagedFile(template, relativePath) {
161
- const patterns = [...MANAGED_FILE_PATTERNS.common, ...(MANAGED_FILE_PATTERNS[template.id] || [])];
162
- return patterns.some((pattern) => pathMatchesPattern(relativePath, pattern));
163
- }
164
-
165
- function collectRelativeFiles(rootDirectory) {
166
- const results = [];
167
-
168
- function visit(currentDirectory) {
169
- const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
170
-
171
- for (const entry of entries) {
172
- const absolutePath = path.join(currentDirectory, entry.name);
173
- const relativePath = normalizePath(path.relative(rootDirectory, absolutePath));
174
-
175
- if (entry.isDirectory()) {
176
- visit(absolutePath);
177
- } else {
178
- results.push(relativePath);
71
+ printPlaywrightInstallRecovery(targetDirectory, colors);
179
72
  }
180
73
  }
181
- }
182
-
183
- visit(rootDirectory);
184
-
185
- return results.sort();
186
- }
187
-
188
- function transformTemplateFile(relativePath, content, targetDirectory, template) {
189
- const packageName = toPackageName(targetDirectory, template);
190
-
191
- if (relativePath === "package.json") {
192
- const pkg = JSON.parse(content);
193
- return `${JSON.stringify({ ...pkg, name: packageName }, null, 2)}\n`;
194
- }
195
-
196
- if (relativePath === "package-lock.json") {
197
- const lock = JSON.parse(content);
198
- return `${JSON.stringify(
199
- {
200
- ...lock,
201
- name: packageName,
202
- packages: lock.packages
203
- ? {
204
- ...lock.packages,
205
- "": {
206
- ...lock.packages[""],
207
- name: packageName
208
- }
209
- }
210
- : lock.packages
211
- },
212
- null,
213
- 2
214
- )}\n`;
215
- }
216
-
217
- return content;
218
- }
219
-
220
- function renderTemplateFile(template, relativePath, targetDirectory) {
221
- if (relativePath === ".gitignore") {
222
- const gitignorePath = path.join(getTemplateDirectory(template.id), ".gitignore");
223
- const gitignoreContent = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : DEFAULT_GITIGNORE;
224
- return transformTemplateFile(relativePath, gitignoreContent, targetDirectory, template);
225
- }
226
-
227
- const sourcePath = path.join(getTemplateDirectory(template.id), relativePath);
228
- const content = fs.readFileSync(sourcePath, "utf8");
229
- return transformTemplateFile(relativePath, content, targetDirectory, template);
230
- }
231
-
232
- function getManagedRelativePaths(template) {
233
- const templateDirectory = getTemplateDirectory(template.id);
234
- const templateFiles = collectRelativeFiles(templateDirectory).filter((relativePath) => isManagedFile(template, relativePath));
235
- const managedFiles = new Set(templateFiles);
236
- managedFiles.add(".gitignore");
237
- managedFiles.delete(METADATA_FILENAME);
238
- return [...managedFiles].sort();
239
- }
240
-
241
- function getMetadataPath(targetDirectory) {
242
- return path.join(targetDirectory, METADATA_FILENAME);
243
- }
244
-
245
- function buildProjectMetadata(template, targetDirectory) {
246
- const managedFiles = {};
247
-
248
- for (const relativePath of getManagedRelativePaths(template)) {
249
- const absolutePath = path.join(targetDirectory, relativePath);
250
- if (!fs.existsSync(absolutePath)) {
251
- continue;
252
- }
253
-
254
- managedFiles[relativePath] = {
255
- baselineHash: sha256(fs.readFileSync(absolutePath, "utf8"))
256
- };
257
- }
258
-
259
- return {
260
- schemaVersion: 1,
261
- template: template.id,
262
- templateVersion: CLI_PACKAGE.version,
263
- packageName: toPackageName(targetDirectory, template),
264
- generatedAt: new Date().toISOString(),
265
- managedFiles
266
74
  };
267
- }
268
-
269
- function writeProjectMetadata(template, targetDirectory, existingMetadata) {
270
- const nextMetadata = buildProjectMetadata(template, targetDirectory);
271
-
272
- if (existingMetadata) {
273
- nextMetadata.generatedAt = existingMetadata.generatedAt || nextMetadata.generatedAt;
274
- nextMetadata.templateVersion = existingMetadata.templateVersion || nextMetadata.templateVersion;
275
- }
276
-
277
- fs.writeFileSync(getMetadataPath(targetDirectory), `${JSON.stringify(nextMetadata, null, 2)}\n`, "utf8");
278
- return nextMetadata;
279
- }
280
-
281
- function readProjectMetadata(targetDirectory) {
282
- const metadataPath = getMetadataPath(targetDirectory);
283
-
284
- if (!fs.existsSync(metadataPath)) {
285
- throw new Error(`No ${METADATA_FILENAME} file found in ${targetDirectory}.`);
286
- }
287
-
288
- return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
289
- }
290
-
291
- function detectTemplateFromProject(targetDirectory) {
292
- const metadataPath = getMetadataPath(targetDirectory);
293
- if (fs.existsSync(metadataPath)) {
294
- const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
295
- return metadata.template;
296
- }
297
-
298
- if (fs.existsSync(path.join(targetDirectory, "playwright.config.ts"))) {
299
- return "playwright-template";
300
- }
301
-
302
- if (fs.existsSync(path.join(targetDirectory, "cypress.config.ts"))) {
303
- return "cypress-template";
304
- }
305
-
306
- throw new Error(`Could not detect the template used for ${targetDirectory}.`);
307
- }
308
-
309
- function analyzeUpgrade(template, targetDirectory, metadata) {
310
- const managedPaths = getManagedRelativePaths(template);
311
- const results = [];
312
-
313
- for (const relativePath of managedPaths) {
314
- const absolutePath = path.join(targetDirectory, relativePath);
315
- const latestContent = renderTemplateFile(template, relativePath, targetDirectory);
316
- const latestHash = sha256(latestContent);
317
- const baselineHash = metadata.managedFiles?.[relativePath]?.baselineHash || null;
318
- const currentExists = fs.existsSync(absolutePath);
319
- const currentHash = currentExists ? sha256(fs.readFileSync(absolutePath, "utf8")) : null;
320
-
321
- let status = "up-to-date";
322
-
323
- if (!baselineHash) {
324
- if (!currentExists) {
325
- status = "new-file";
326
- } else if (currentHash === latestHash) {
327
- status = "new-file";
328
- } else {
329
- status = "conflict";
330
- }
331
- } else if (!currentExists) {
332
- status = "conflict";
333
- } else if (currentHash === latestHash) {
334
- status = "up-to-date";
335
- } else if (currentHash === baselineHash) {
336
- status = "safe-update";
337
- } else {
338
- status = "conflict";
339
- }
340
-
341
- results.push({
342
- relativePath,
343
- status,
344
- latestContent,
345
- latestHash,
346
- currentHash,
347
- baselineHash,
348
- currentExists
349
- });
350
- }
351
-
352
- return results;
353
- }
354
-
355
- function printHelp() {
356
- const supportedTemplates = TEMPLATES.map((template) => ` ${template.id}${template.aliases.length > 0 ? ` (${template.aliases.join(", ")})` : ""}`).join("\n");
357
-
358
- process.stdout.write(`${colors.bold("create-qa-patterns")}
359
-
360
- Usage:
361
- create-qa-patterns
362
- create-qa-patterns <target-directory>
363
- create-qa-patterns <template> [target-directory]
364
- create-qa-patterns --template <template> [target-directory]
365
- create-qa-patterns upgrade check [target-directory]
366
- create-qa-patterns upgrade apply --safe [target-directory]
367
-
368
- Options:
369
- --yes Accept all post-generate prompts
370
- --no-install Skip npm install
371
- --no-setup Skip template-specific setup such as Playwright browser install
372
- --no-test Skip npm test
373
- --template Explicitly choose a template without using positional arguments
374
- --safe Required with upgrade apply; only updates unchanged managed files
375
-
376
- Interactive mode:
377
- When run without an explicit template, the CLI shows an interactive template picker.
378
-
379
- Supported templates:
380
- ${supportedTemplates}
381
- `);
382
- }
383
-
384
- function parseCliOptions(args) {
385
- const options = {
386
- yes: false,
387
- noInstall: false,
388
- noSetup: false,
389
- noTest: false,
390
- safe: false,
391
- templateName: null,
392
- positionalArgs: []
393
- };
394
-
395
- for (let index = 0; index < args.length; index += 1) {
396
- const arg = args[index];
397
-
398
- switch (arg) {
399
- case "--yes":
400
- options.yes = true;
401
- break;
402
- case "--no-install":
403
- options.noInstall = true;
404
- break;
405
- case "--no-setup":
406
- options.noSetup = true;
407
- break;
408
- case "--no-test":
409
- options.noTest = true;
410
- break;
411
- case "--safe":
412
- options.safe = true;
413
- break;
414
- case "--template": {
415
- const templateValue = args[index + 1];
416
- if (!templateValue) {
417
- throw new Error("Missing value for --template.");
418
- }
419
-
420
- const templateName = resolveTemplate(templateValue);
421
- if (!templateName) {
422
- throw new Error(
423
- `Unsupported template "${templateValue}". Supported templates: ${TEMPLATES.map((template) => template.id).join(", ")}.`
424
- );
425
- }
426
-
427
- options.templateName = templateName;
428
- index += 1;
429
- break;
430
- }
431
- default:
432
- options.positionalArgs.push(arg);
433
- break;
434
- }
435
- }
436
-
437
- return options;
438
- }
75
+ });
439
76
 
440
- function parseNodeVersion(version) {
441
- const normalized = version.replace(/^v/, "");
442
- const [major = "0", minor = "0", patch = "0"] = normalized.split(".");
77
+ const TEMPLATE_ALIASES = createTemplateAliases(TEMPLATES);
78
+ const SUPPORTED_TEMPLATE_IDS = TEMPLATES.map((template) => template.id);
443
79
 
80
+ function createMetadataOptions() {
444
81
  return {
445
- major: Number.parseInt(major, 10),
446
- minor: Number.parseInt(minor, 10),
447
- patch: Number.parseInt(patch, 10)
82
+ cliPackageVersion: CLI_PACKAGE_VERSION,
83
+ defaultGitignore: DEFAULT_GITIGNORE,
84
+ getTemplateDirectory: (templateId) =>
85
+ getTemplateDirectory(__dirname, templateId),
86
+ managedPatterns: MANAGED_FILE_PATTERNS,
87
+ metadataFilename: METADATA_FILENAME,
88
+ toPackageName
448
89
  };
449
90
  }
450
91
 
451
- function isNodeVersionSupported(version) {
452
- if (version.major !== MIN_NODE_VERSION.major) {
453
- return version.major > MIN_NODE_VERSION.major;
454
- }
455
-
456
- if (version.minor !== MIN_NODE_VERSION.minor) {
457
- return version.minor > MIN_NODE_VERSION.minor;
458
- }
459
-
460
- return version.patch >= MIN_NODE_VERSION.patch;
461
- }
462
-
463
- function assertSupportedNodeVersion() {
464
- const currentVersion = parseNodeVersion(process.version);
465
-
466
- if (!isNodeVersionSupported(currentVersion)) {
467
- throw new Error(
468
- `Node ${MIN_NODE_VERSION.major}.${MIN_NODE_VERSION.minor}.${MIN_NODE_VERSION.patch}+ is required. Current version: ${process.version}`
469
- );
470
- }
471
- }
472
-
473
- function resolveTemplate(value) {
474
- return TEMPLATE_ALIASES.get(value);
475
- }
476
-
477
- function getTemplate(templateId) {
478
- return TEMPLATES.find((template) => template.id === templateId);
479
- }
480
-
481
- function sleep(ms) {
482
- return new Promise((resolve) => setTimeout(resolve, ms));
483
- }
484
-
485
- function commandExists(command) {
486
- const result = spawnSync(getCommandName(command), ["--version"], {
487
- stdio: "ignore"
488
- });
489
-
490
- return !result.error && result.status === 0;
491
- }
492
-
493
- function collectPrerequisites() {
494
- return {
495
- npm: commandExists("npm"),
496
- npx: commandExists("npx"),
497
- docker: commandExists("docker"),
498
- git: commandExists("git")
499
- };
500
- }
501
-
502
- function printPrerequisiteWarnings(prerequisites) {
503
- if (!prerequisites.npm) {
504
- process.stdout.write(`${colors.yellow("Warning:")} npm was not found. Automated install and test steps will be unavailable.\n`);
505
- }
506
-
507
- if (!prerequisites.npx) {
508
- process.stdout.write(`${colors.yellow("Warning:")} npx was not found. Template setup steps that depend on npx will be unavailable.\n`);
509
- }
510
-
511
- if (!prerequisites.docker) {
512
- process.stdout.write(`${colors.yellow("Warning:")} docker was not found. Docker-based template flows will not run until Docker is installed.\n`);
513
- }
514
-
515
- if (!prerequisites.git) {
516
- process.stdout.write(`${colors.yellow("Warning:")} git was not found. The generated project cannot be initialized as a repository automatically.\n`);
517
- }
518
-
519
- if (!prerequisites.npm || !prerequisites.npx || !prerequisites.docker || !prerequisites.git) {
520
- process.stdout.write("\n");
521
- }
522
- }
523
-
524
- function createLineInterface() {
525
- return readline.createInterface({
526
- input: process.stdin,
527
- output: process.stdout
528
- });
529
- }
530
-
531
92
  function createSummary(template, targetDirectory, generatedInCurrentDirectory) {
532
93
  return {
533
94
  template,
534
95
  targetDirectory,
96
+ targetRelativePath: path.relative(process.cwd(), targetDirectory) || '.',
535
97
  generatedInCurrentDirectory,
536
98
  demoAppsManagedByTemplate: Boolean(template.demoAppsManagedByTemplate),
537
- gitInit: "not-run",
538
- npmInstall: "not-run",
539
- extraSetup: template.setup ? "not-run" : null,
540
- testRun: "not-run"
99
+ localCredentials: null,
100
+ gitInit: 'not-run',
101
+ npmInstall: 'not-run',
102
+ extraSetup: template.setup ? 'not-run' : null,
103
+ testRun: 'not-run'
541
104
  };
542
105
  }
543
106
 
544
- function askQuestion(prompt) {
545
- const lineInterface = createLineInterface();
546
-
547
- return new Promise((resolve) => {
548
- lineInterface.question(prompt, (answer) => {
549
- lineInterface.close();
550
- resolve(answer.trim());
551
- });
552
- });
553
- }
554
-
555
- async function askYesNo(prompt, defaultValue = true) {
556
- const suffix = defaultValue ? " [Y/n] " : " [y/N] ";
557
-
558
- while (true) {
559
- const answer = (await askQuestion(`${prompt}${suffix}`)).toLowerCase();
560
-
561
- if (!answer) {
562
- return defaultValue;
563
- }
564
-
565
- if (["y", "yes"].includes(answer)) {
566
- return true;
567
- }
568
-
569
- if (["n", "no"].includes(answer)) {
570
- return false;
571
- }
572
-
573
- process.stdout.write("Please answer yes or no.\n");
574
- }
575
- }
576
-
577
- async function selectTemplateInteractively() {
578
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
579
- return DEFAULT_TEMPLATE;
580
- }
581
-
582
- readline.emitKeypressEvents(process.stdin);
583
-
584
- if (typeof process.stdin.setRawMode === "function") {
585
- process.stdin.setRawMode(true);
586
- }
587
-
588
- let selectedIndex = 0;
589
- let renderedLines = 0;
590
-
591
- const render = () => {
592
- if (renderedLines > 0) {
593
- readline.moveCursor(process.stdout, 0, -renderedLines);
594
- readline.clearScreenDown(process.stdout);
595
- }
596
-
597
- const lines = [
598
- "Select a template",
599
- "Use ↑/↓ to choose and press Enter to continue.",
600
- ""
601
- ];
602
-
603
- for (let index = 0; index < TEMPLATES.length; index += 1) {
604
- const template = TEMPLATES[index];
605
- const marker = index === selectedIndex ? ">" : " ";
606
- lines.push(`${marker} ${template.label}`);
607
- lines.push(` ${template.description}`);
608
- lines.push("");
609
- }
610
-
611
- renderedLines = lines.length;
612
- process.stdout.write(`${lines.join("\n")}\n`);
613
- };
614
-
615
- render();
616
-
617
- return new Promise((resolve) => {
618
- const handleKeypress = (_, key) => {
619
- if (!key) {
620
- return;
621
- }
622
-
623
- if (key.name === "up") {
624
- selectedIndex = (selectedIndex - 1 + TEMPLATES.length) % TEMPLATES.length;
625
- render();
626
- return;
627
- }
628
-
629
- if (key.name === "down") {
630
- selectedIndex = (selectedIndex + 1) % TEMPLATES.length;
631
- render();
632
- return;
633
- }
634
-
635
- if (key.name === "return") {
636
- process.stdin.off("keypress", handleKeypress);
637
- if (typeof process.stdin.setRawMode === "function") {
638
- process.stdin.setRawMode(false);
639
- }
640
- readline.clearScreenDown(process.stdout);
641
- process.stdout.write(`Selected: ${TEMPLATES[selectedIndex].label}\n\n`);
642
- resolve(TEMPLATES[selectedIndex].id);
643
- return;
644
- }
645
-
646
- if (key.ctrl && key.name === "c") {
647
- process.stdin.off("keypress", handleKeypress);
648
- if (typeof process.stdin.setRawMode === "function") {
649
- process.stdin.setRawMode(false);
650
- }
651
- process.stdout.write("\n");
652
- process.exit(1);
653
- }
654
- };
655
-
656
- process.stdin.on("keypress", handleKeypress);
657
- });
658
- }
659
-
660
- function resolveNonInteractiveArgs(args, options = {}) {
661
- if (options.templateName) {
662
- if (args.length > 1) {
663
- throw new Error("Too many arguments. Run `create-qa-patterns --help` for usage.");
664
- }
665
-
666
- if (args.length === 0) {
667
- return {
668
- templateName: options.templateName,
669
- targetDirectory: process.cwd(),
670
- generatedInCurrentDirectory: true
671
- };
672
- }
673
-
674
- return {
675
- templateName: options.templateName,
676
- targetDirectory: path.resolve(process.cwd(), args[0]),
677
- generatedInCurrentDirectory: false
678
- };
679
- }
680
-
681
- if (args.length === 0) {
682
- return {
683
- templateName: DEFAULT_TEMPLATE,
684
- targetDirectory: process.cwd(),
685
- generatedInCurrentDirectory: true
686
- };
687
- }
688
-
689
- if (args.length === 1) {
690
- const templateName = resolveTemplate(args[0]);
691
-
692
- if (templateName) {
693
- return {
694
- templateName,
695
- targetDirectory: process.cwd(),
696
- generatedInCurrentDirectory: true
697
- };
698
- }
699
-
700
- return {
701
- templateName: DEFAULT_TEMPLATE,
702
- targetDirectory: path.resolve(process.cwd(), args[0]),
703
- generatedInCurrentDirectory: false
704
- };
705
- }
706
-
707
- if (args.length === 2) {
708
- const templateName = resolveTemplate(args[0]);
709
-
710
- if (!templateName) {
711
- throw new Error(
712
- `Unsupported template "${args[0]}". Supported templates: ${TEMPLATES.map((template) => template.id).join(", ")}.`
713
- );
714
- }
715
-
716
- return {
717
- templateName,
718
- targetDirectory: path.resolve(process.cwd(), args[1]),
719
- generatedInCurrentDirectory: false
720
- };
721
- }
722
-
723
- throw new Error("Too many arguments. Run `create-qa-patterns --help` for usage.");
724
- }
725
-
726
107
  async function resolveScaffoldArgs(args) {
727
- const explicitTemplate = args[0] && resolveTemplate(args[0]);
108
+ const explicitTemplate =
109
+ args[0] && resolveTemplate(TEMPLATE_ALIASES, args[0]);
110
+ const nonInteractiveOptions = {
111
+ defaultTemplate: DEFAULT_TEMPLATE,
112
+ resolveTemplate: (value) => resolveTemplate(TEMPLATE_ALIASES, value),
113
+ supportedTemplateIds: SUPPORTED_TEMPLATE_IDS
114
+ };
728
115
 
729
116
  if (explicitTemplate) {
730
- return resolveNonInteractiveArgs(args);
117
+ return resolveNonInteractiveArgs(args, nonInteractiveOptions);
731
118
  }
732
119
 
733
120
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
734
- return resolveNonInteractiveArgs(args);
121
+ return resolveNonInteractiveArgs(args, nonInteractiveOptions);
735
122
  }
736
123
 
737
- const templateName = await selectTemplateInteractively();
738
- const defaultTarget = args[0] ? args[0] : ".";
739
- const targetAnswer = await askQuestion(`Target directory (${defaultTarget}): `);
124
+ const templateName = await selectTemplateInteractively(TEMPLATES);
125
+ const defaultTarget = args[0] ? args[0] : '.';
126
+ const targetAnswer = await askQuestion(
127
+ `Target directory (${defaultTarget}): `
128
+ );
740
129
  const targetValue = targetAnswer || defaultTarget;
741
130
  const targetDirectory = path.resolve(process.cwd(), targetValue);
742
131
 
@@ -747,327 +136,21 @@ async function resolveScaffoldArgs(args) {
747
136
  };
748
137
  }
749
138
 
750
- function ensureScaffoldTarget(targetDirectory) {
751
- if (!fs.existsSync(targetDirectory)) {
752
- fs.mkdirSync(targetDirectory, { recursive: true });
753
- return;
754
- }
755
-
756
- const entries = fs
757
- .readdirSync(targetDirectory)
758
- .filter((entry) => ![".git", ".DS_Store"].includes(entry));
759
-
760
- if (entries.length > 0) {
761
- throw new Error(`Target directory is not empty: ${targetDirectory}`);
762
- }
763
- }
764
-
765
- function toPackageName(targetDirectory, template) {
766
- const baseName = path.basename(targetDirectory).toLowerCase();
767
- const normalized = baseName
768
- .replace(/[^a-z0-9-_]+/g, "-")
769
- .replace(/^-+|-+$/g, "")
770
- .replace(/-{2,}/g, "-");
771
-
772
- return normalized || template.defaultPackageName || "qa-patterns-template";
773
- }
774
-
775
- function updateJsonFile(filePath, update) {
776
- const current = JSON.parse(fs.readFileSync(filePath, "utf8"));
777
- const next = update(current);
778
- fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
779
- }
780
-
781
- function customizeProject(targetDirectory, template) {
782
- const packageJsonPath = path.join(targetDirectory, "package.json");
783
- const packageLockPath = path.join(targetDirectory, "package-lock.json");
784
- const gitignorePath = path.join(targetDirectory, ".gitignore");
785
-
786
- if (fs.existsSync(packageJsonPath)) {
787
- fs.writeFileSync(
788
- packageJsonPath,
789
- transformTemplateFile("package.json", fs.readFileSync(packageJsonPath, "utf8"), targetDirectory, template),
790
- "utf8"
791
- );
792
- }
793
-
794
- if (fs.existsSync(packageLockPath)) {
795
- fs.writeFileSync(
796
- packageLockPath,
797
- transformTemplateFile("package-lock.json", fs.readFileSync(packageLockPath, "utf8"), targetDirectory, template),
798
- "utf8"
799
- );
800
- }
801
-
802
- if (!fs.existsSync(gitignorePath)) {
803
- fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE, "utf8");
804
- }
805
- }
806
-
807
- function initializeGitRepository(targetDirectory) {
808
- if (fs.existsSync(path.join(targetDirectory, ".git"))) {
809
- return;
810
- }
811
-
812
- const result = spawnSync(getCommandName("git"), ["init"], {
813
- cwd: targetDirectory,
814
- encoding: "utf8"
815
- });
816
-
817
- if (result.status !== 0) {
818
- throw new Error(result.stderr || "git init failed.");
819
- }
820
- }
139
+ let lastProgressLineLength = 0;
821
140
 
822
141
  function renderProgress(completed, total, label) {
823
142
  const width = 24;
824
143
  const filled = Math.round((completed / total) * width);
825
144
  const empty = width - filled;
826
- const bar = `${"=".repeat(filled)}${" ".repeat(empty)}`;
827
- const percentage = `${Math.round((completed / total) * 100)}`.padStart(3, " ");
828
- process.stdout.write(`\r[${bar}] ${percentage}% ${label}`);
829
- }
830
-
831
- async function scaffoldProject(template, targetDirectory, prerequisites) {
832
- const templateName = template.id;
833
- const templateDirectory = path.resolve(__dirname, "templates", templateName);
834
-
835
- if (!fs.existsSync(templateDirectory)) {
836
- throw new Error(`Template files are missing for "${templateName}".`);
837
- }
838
-
839
- const steps = [
840
- "Validating target directory",
841
- "Copying template files",
842
- "Customizing project files",
843
- "Finalizing scaffold"
844
- ];
845
-
846
- renderProgress(0, steps.length, "Preparing scaffold");
847
- ensureScaffoldTarget(targetDirectory);
848
- await sleep(60);
849
-
850
- renderProgress(1, steps.length, steps[0]);
851
- await sleep(80);
852
-
853
- fs.cpSync(templateDirectory, targetDirectory, { recursive: true });
854
- renderProgress(2, steps.length, steps[1]);
855
- await sleep(80);
856
-
857
- customizeProject(targetDirectory, template);
858
- renderProgress(3, steps.length, steps[2]);
859
- await sleep(80);
860
-
861
- if (prerequisites.git) {
862
- initializeGitRepository(targetDirectory);
863
- }
864
-
865
- renderProgress(4, steps.length, steps[3]);
866
- await sleep(60);
867
- process.stdout.write("\n");
868
- }
869
-
870
- function getCommandName(base) {
871
- if (process.platform === "win32") {
872
- return `${base}.cmd`;
873
- }
874
-
875
- return base;
876
- }
877
-
878
- function printPlaywrightInstallRecovery(targetDirectory) {
879
- process.stdout.write(`
880
- ${colors.yellow("Playwright browser installation did not complete.")}
881
-
882
- Common cause:
883
- Missing OS packages required to run Playwright browsers.
884
-
885
- Recommended next steps:
886
- cd ${path.relative(process.cwd(), targetDirectory) || "."}
887
- sudo npx playwright install-deps
888
- npx playwright install
889
-
890
- If you already know the missing package name, install it with your system package manager and then rerun:
891
- npx playwright install
892
-
893
- The template was generated successfully. You can complete browser setup later.
894
-
895
- `);
896
- }
897
-
898
- function runCommand(command, args, cwd) {
899
- return new Promise((resolve, reject) => {
900
- const child = spawn(getCommandName(command), args, {
901
- cwd,
902
- stdio: "inherit"
903
- });
904
-
905
- child.on("close", (code) => {
906
- if (code === 0) {
907
- resolve();
908
- return;
909
- }
910
-
911
- reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
912
- });
913
-
914
- child.on("error", reject);
915
- });
916
- }
917
-
918
- function printSuccess(template, targetDirectory, generatedInCurrentDirectory) {
919
- process.stdout.write(`\n${colors.green(colors.bold("Success"))}
920
- Generated ${template ? template.label : template.id} in ${targetDirectory}
921
- \n`);
922
-
923
- if (!generatedInCurrentDirectory) {
924
- process.stdout.write(`${colors.cyan("Change directory first:")}\n cd ${path.relative(process.cwd(), targetDirectory) || "."}\n\n`);
925
- }
926
- }
927
-
928
- function printNextSteps(summary) {
929
- const steps = [];
930
-
931
- if (!summary.generatedInCurrentDirectory) {
932
- steps.push(`cd ${path.relative(process.cwd(), summary.targetDirectory) || "."}`);
933
- }
934
-
935
- if (summary.npmInstall !== "completed") {
936
- steps.push("npm install");
937
- }
938
-
939
- if (summary.template.setup && summary.extraSetup !== "completed") {
940
- steps.push(summary.template.setup.nextStep);
941
- }
942
-
943
- if (summary.testRun !== "completed") {
944
- steps.push("npm test");
945
- }
946
-
947
- if (steps.length > 0) {
948
- process.stdout.write(`${colors.cyan("Next steps:")}\n`);
949
- for (const step of steps) {
950
- process.stdout.write(` ${step}\n`);
951
- }
952
- process.stdout.write("\n");
953
- }
954
-
955
- if (summary.demoAppsManagedByTemplate) {
956
- process.stdout.write(
957
- `${colors.yellow(colors.bold("Demo apps included:"))} sample tests run against bundled demo apps in local ${colors.bold("dev")}. Delete or replace ${colors.bold("demo-apps/")} if you do not want them.\n`
958
- );
959
- }
960
-
961
- process.stdout.write(`${colors.green(colors.bold("Happy testing."))}\n`);
962
- }
963
-
964
- function formatStatus(status) {
965
- switch (status) {
966
- case "completed":
967
- return colors.green("completed");
968
- case "skipped":
969
- return colors.dim("skipped");
970
- case "unavailable":
971
- return colors.yellow("unavailable");
972
- case "manual-recovery":
973
- return colors.yellow("manual recovery required");
974
- default:
975
- return colors.dim("not run");
976
- }
977
- }
978
-
979
- function formatUpgradeStatus(status) {
980
- switch (status) {
981
- case "safe-update":
982
- return colors.green("safe update available");
983
- case "new-file":
984
- return colors.green("new managed file available");
985
- case "conflict":
986
- return colors.yellow("manual review required");
987
- default:
988
- return colors.dim("up to date");
989
- }
990
- }
991
-
992
- function printUpgradeReport(targetDirectory, metadata, results) {
993
- const safeCount = results.filter((entry) => entry.status === "safe-update").length;
994
- const newCount = results.filter((entry) => entry.status === "new-file").length;
995
- const conflictCount = results.filter((entry) => entry.status === "conflict").length;
996
-
997
- process.stdout.write(`\n${colors.bold("Upgrade check")}\n`);
998
- process.stdout.write(` Target: ${targetDirectory}\n`);
999
- process.stdout.write(` Template: ${metadata.template}\n`);
1000
- process.stdout.write(` Current baseline version: ${metadata.templateVersion}\n`);
1001
- process.stdout.write(` CLI template version: ${CLI_PACKAGE.version}\n`);
1002
- process.stdout.write(` Safe updates: ${safeCount}\n`);
1003
- process.stdout.write(` New managed files: ${newCount}\n`);
1004
- process.stdout.write(` Conflicts: ${conflictCount}\n\n`);
1005
-
1006
- for (const entry of results) {
1007
- if (entry.status === "up-to-date") {
1008
- continue;
1009
- }
1010
-
1011
- process.stdout.write(` ${entry.relativePath}: ${formatUpgradeStatus(entry.status)}\n`);
1012
- }
1013
-
1014
- if (safeCount === 0 && newCount === 0 && conflictCount === 0) {
1015
- process.stdout.write(`${colors.green("Everything already matches the current managed template files.")}\n`);
1016
- }
1017
-
1018
- process.stdout.write("\n");
1019
- }
1020
-
1021
- function applySafeUpdates(targetDirectory, metadata, results) {
1022
- const nextMetadata = {
1023
- ...metadata,
1024
- managedFiles: {
1025
- ...metadata.managedFiles
1026
- }
1027
- };
1028
-
1029
- let appliedCount = 0;
1030
-
1031
- for (const entry of results) {
1032
- if (!["safe-update", "new-file"].includes(entry.status)) {
1033
- continue;
1034
- }
1035
-
1036
- const absolutePath = path.join(targetDirectory, entry.relativePath);
1037
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
1038
- fs.writeFileSync(absolutePath, entry.latestContent, "utf8");
1039
- nextMetadata.managedFiles[entry.relativePath] = {
1040
- baselineHash: entry.latestHash
1041
- };
1042
- appliedCount += 1;
1043
- }
1044
-
1045
- const remainingConflicts = results.filter((entry) => entry.status === "conflict").length;
1046
- if (remainingConflicts === 0) {
1047
- nextMetadata.templateVersion = CLI_PACKAGE.version;
1048
- }
1049
-
1050
- fs.writeFileSync(getMetadataPath(targetDirectory), `${JSON.stringify(nextMetadata, null, 2)}\n`, "utf8");
1051
-
1052
- process.stdout.write(`\n${colors.bold("Upgrade apply")}\n`);
1053
- process.stdout.write(` Applied safe updates: ${appliedCount}\n`);
1054
- process.stdout.write(` Remaining conflicts: ${remainingConflicts}\n`);
1055
- process.stdout.write("\n");
1056
- }
1057
-
1058
- function printSummary(summary) {
1059
- process.stdout.write(`\n${colors.bold("Summary")}\n`);
1060
- process.stdout.write(` Template: ${summary.template.id}\n`);
1061
- process.stdout.write(` Target: ${summary.targetDirectory}\n`);
1062
- process.stdout.write(` Git repository: ${formatStatus(summary.gitInit)}\n`);
1063
- process.stdout.write(
1064
- ` Demo apps: ${summary.demoAppsManagedByTemplate ? "bundled and auto-started in dev when using default local URLs" : "external application required"}\n`
145
+ const bar = `${'='.repeat(filled)}${' '.repeat(empty)}`;
146
+ const percentage = `${Math.round((completed / total) * 100)}`.padStart(
147
+ 3,
148
+ ' '
1065
149
  );
1066
- process.stdout.write(` npm install: ${formatStatus(summary.npmInstall)}\n`);
1067
- if (summary.template.setup) {
1068
- process.stdout.write(` ${summary.template.setup.summaryLabel}: ${formatStatus(summary.extraSetup)}\n`);
1069
- }
1070
- process.stdout.write(` npm test: ${formatStatus(summary.testRun)}\n`);
150
+ const line = `[${bar}] ${percentage}% ${label}`;
151
+ const paddingLength = Math.max(0, lastProgressLineLength - line.length);
152
+ process.stdout.write(`\r${line}${' '.repeat(paddingLength)}`);
153
+ lastProgressLineLength = line.length;
1071
154
  }
1072
155
 
1073
156
  async function runPostGenerateActions(template, targetDirectory, summary) {
@@ -1077,112 +160,171 @@ async function runPostGenerateActions(template, targetDirectory, summary) {
1077
160
 
1078
161
  if (prerequisites.npm) {
1079
162
  if (options.noInstall) {
1080
- summary.npmInstall = "skipped";
163
+ summary.npmInstall = 'skipped';
1081
164
  } else {
1082
- const shouldInstallDependencies = options.yes ? true : canPrompt ? await askYesNo("Run npm install now?", true) : false;
165
+ const shouldInstallDependencies = options.yes
166
+ ? true
167
+ : canPrompt
168
+ ? await askYesNo('Run npm install now?', true)
169
+ : false;
1083
170
 
1084
171
  if (shouldInstallDependencies) {
1085
- await runCommand("npm", ["install"], targetDirectory);
1086
- summary.npmInstall = "completed";
172
+ await runCommand('npm', ['install'], targetDirectory);
173
+ summary.npmInstall = 'completed';
1087
174
  } else {
1088
- summary.npmInstall = canPrompt ? "skipped" : "not-run";
175
+ summary.npmInstall = canPrompt ? 'skipped' : 'not-run';
1089
176
  }
1090
177
  }
1091
178
  } else {
1092
- process.stdout.write(`${colors.yellow("Skipping")} npm install prompt because npm is not available.\n`);
1093
- summary.npmInstall = "unavailable";
179
+ process.stdout.write(
180
+ `${colors.yellow('Skipping')} npm install prompt because npm is not available.\n`
181
+ );
182
+ summary.npmInstall = 'unavailable';
1094
183
  }
1095
184
 
1096
185
  if (template.setup) {
1097
186
  if (options.noSetup) {
1098
- summary.extraSetup = "skipped";
187
+ summary.extraSetup = 'skipped';
1099
188
  } else if (prerequisites[template.setup.availability]) {
1100
- const shouldRunExtraSetup = options.yes ? true : canPrompt ? await askYesNo(template.setup.prompt, true) : false;
189
+ const shouldRunExtraSetup = options.yes
190
+ ? true
191
+ : canPrompt
192
+ ? await askYesNo(template.setup.prompt, true)
193
+ : false;
1101
194
 
1102
195
  if (shouldRunExtraSetup) {
1103
196
  try {
1104
197
  await template.setup.run(targetDirectory);
1105
- summary.extraSetup = "completed";
198
+ summary.extraSetup = 'completed';
1106
199
  } catch (error) {
1107
- summary.extraSetup = "manual-recovery";
1108
- if (typeof template.setup.recovery === "function") {
200
+ summary.extraSetup = 'manual-recovery';
201
+ if (typeof template.setup.recovery === 'function') {
1109
202
  template.setup.recovery(targetDirectory);
1110
203
  }
1111
204
 
1112
- const shouldContinue = await askYesNo("Continue without completing setup?", true);
205
+ const shouldContinue = await askYesNo(
206
+ 'Continue without completing setup?',
207
+ true
208
+ );
1113
209
 
1114
210
  if (!shouldContinue) {
1115
211
  throw error;
1116
212
  }
1117
213
  }
1118
214
  } else {
1119
- summary.extraSetup = canPrompt ? "skipped" : "not-run";
215
+ summary.extraSetup = canPrompt ? 'skipped' : 'not-run';
1120
216
  }
1121
217
  } else {
1122
218
  process.stdout.write(
1123
- `${colors.yellow("Skipping")} ${template.setup.summaryLabel.toLowerCase()} prompt because ${template.setup.availability} is not available.\n`
219
+ `${colors.yellow('Skipping')} ${template.setup.summaryLabel.toLowerCase()} prompt because ${template.setup.availability} is not available.\n`
1124
220
  );
1125
- summary.extraSetup = "unavailable";
221
+ summary.extraSetup = 'unavailable';
1126
222
  }
1127
223
  }
1128
224
 
1129
225
  if (prerequisites.npm) {
1130
226
  if (options.noTest) {
1131
- summary.testRun = "skipped";
227
+ summary.testRun = 'skipped';
1132
228
  } else {
1133
- const shouldRunTests = options.yes ? true : canPrompt ? await askYesNo("Run npm test now?", false) : false;
229
+ const shouldRunTests = options.yes
230
+ ? true
231
+ : canPrompt
232
+ ? await askYesNo('Run npm test now?', false)
233
+ : false;
1134
234
 
1135
235
  if (shouldRunTests) {
1136
- await runCommand("npm", ["test"], targetDirectory);
1137
- summary.testRun = "completed";
236
+ await runCommand('npm', ['test'], targetDirectory);
237
+ summary.testRun = 'completed';
1138
238
  } else {
1139
- summary.testRun = canPrompt ? "skipped" : "not-run";
239
+ summary.testRun = canPrompt ? 'skipped' : 'not-run';
1140
240
  }
1141
241
  }
1142
242
  } else {
1143
- process.stdout.write(`${colors.yellow("Skipping")} npm test prompt because npm is not available.\n`);
1144
- summary.testRun = "unavailable";
243
+ process.stdout.write(
244
+ `${colors.yellow('Skipping')} npm test prompt because npm is not available.\n`
245
+ );
246
+ summary.testRun = 'unavailable';
1145
247
  }
1146
248
  }
1147
249
 
1148
250
  function resolveUpgradeTarget(args) {
1149
251
  if (args.length > 1) {
1150
- throw new Error("Too many arguments for upgrade. Use `create-qa-patterns upgrade check [target-directory]`.");
252
+ throw new Error(
253
+ 'Too many arguments for upgrade. Use `create-qa-patterns upgrade check [target-directory]`.'
254
+ );
1151
255
  }
1152
256
 
1153
- return path.resolve(process.cwd(), args[0] || ".");
257
+ return path.resolve(process.cwd(), args[0] || '.');
1154
258
  }
1155
259
 
1156
260
  function runUpgradeCommand(rawArgs) {
1157
- const [subcommand = "check", ...rest] = rawArgs;
1158
- const options = parseCliOptions(rest);
261
+ const [subcommand = 'check', ...rest] = rawArgs;
262
+ const metadataOptions = createMetadataOptions();
263
+ const options = parseCliOptions(rest, {
264
+ resolveTemplate: (value) => resolveTemplate(TEMPLATE_ALIASES, value),
265
+ supportedTemplateIds: SUPPORTED_TEMPLATE_IDS
266
+ });
1159
267
  const targetDirectory = resolveUpgradeTarget(options.positionalArgs);
1160
- const metadata = readProjectMetadata(targetDirectory);
1161
- const templateId = metadata.template || detectTemplateFromProject(targetDirectory);
1162
- const template = getTemplate(templateId);
268
+ const metadata = readProjectMetadata(targetDirectory, METADATA_FILENAME);
269
+ const templateId =
270
+ metadata.template ||
271
+ detectTemplateFromProject(targetDirectory, METADATA_FILENAME);
272
+ const template = getTemplate(TEMPLATES, templateId);
1163
273
 
1164
274
  if (!template) {
1165
275
  throw new Error(`Unsupported template "${templateId}".`);
1166
276
  }
1167
277
 
1168
- const results = analyzeUpgrade(template, targetDirectory, metadata);
278
+ const results = analyzeUpgrade(
279
+ template,
280
+ targetDirectory,
281
+ metadata,
282
+ metadataOptions
283
+ );
1169
284
 
1170
- if (subcommand === "check" || subcommand === "report") {
1171
- printUpgradeReport(targetDirectory, metadata, results);
285
+ if (subcommand === 'check' || subcommand === 'report') {
286
+ printUpgradeReport(
287
+ targetDirectory,
288
+ metadata,
289
+ results,
290
+ CLI_PACKAGE_VERSION,
291
+ colors
292
+ );
1172
293
  return;
1173
294
  }
1174
295
 
1175
- if (subcommand === "apply") {
296
+ if (subcommand === 'apply') {
1176
297
  if (!options.safe) {
1177
- throw new Error("Upgrade apply requires --safe. Only safe managed-file updates are supported.");
298
+ throw new Error(
299
+ 'Upgrade apply requires --safe. Only safe managed-file updates are supported.'
300
+ );
1178
301
  }
1179
302
 
1180
- printUpgradeReport(targetDirectory, metadata, results);
1181
- applySafeUpdates(targetDirectory, metadata, results);
303
+ printUpgradeReport(
304
+ targetDirectory,
305
+ metadata,
306
+ results,
307
+ CLI_PACKAGE_VERSION,
308
+ colors
309
+ );
310
+ const outcome = applySafeUpdates(
311
+ targetDirectory,
312
+ metadata,
313
+ results,
314
+ metadataOptions
315
+ );
316
+ process.stdout.write(`\n${colors.bold('Upgrade apply')}\n`);
317
+ process.stdout.write(` Applied safe updates: ${outcome.appliedCount}\n`);
318
+ process.stdout.write(
319
+ ` Remaining conflicts: ${outcome.remainingConflicts}\n`
320
+ );
321
+ process.stdout.write('\n');
1182
322
  return;
1183
323
  }
1184
324
 
1185
- throw new Error(`Unsupported upgrade command "${subcommand}". Use check, report, or apply --safe.`);
325
+ throw new Error(
326
+ `Unsupported upgrade command "${subcommand}". Use check, report, or apply --safe.`
327
+ );
1186
328
  }
1187
329
 
1188
330
  async function main() {
@@ -1190,39 +332,73 @@ async function main() {
1190
332
 
1191
333
  assertSupportedNodeVersion();
1192
334
 
1193
- if (rawArgs[0] === "upgrade") {
335
+ if (rawArgs[0] === 'upgrade') {
1194
336
  runUpgradeCommand(rawArgs.slice(1));
1195
337
  return;
1196
338
  }
1197
339
 
1198
- if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
1199
- printHelp();
340
+ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
341
+ printHelp(TEMPLATES, colors, DEFAULT_TEMPLATE);
1200
342
  return;
1201
343
  }
1202
344
 
1203
- const options = parseCliOptions(rawArgs);
345
+ const metadataOptions = createMetadataOptions();
346
+ const options = parseCliOptions(rawArgs, {
347
+ resolveTemplate: (value) => resolveTemplate(TEMPLATE_ALIASES, value),
348
+ supportedTemplateIds: SUPPORTED_TEMPLATE_IDS
349
+ });
1204
350
  const args = options.positionalArgs;
1205
- const { templateName, targetDirectory, generatedInCurrentDirectory } = options.templateName
1206
- ? resolveNonInteractiveArgs(args, options)
1207
- : await resolveScaffoldArgs(args);
1208
- const template = getTemplate(templateName);
351
+ const { templateName, targetDirectory, generatedInCurrentDirectory } =
352
+ options.templateName
353
+ ? resolveNonInteractiveArgs(args, {
354
+ ...options,
355
+ defaultTemplate: DEFAULT_TEMPLATE,
356
+ resolveTemplate: (value) => resolveTemplate(TEMPLATE_ALIASES, value),
357
+ supportedTemplateIds: SUPPORTED_TEMPLATE_IDS
358
+ })
359
+ : await resolveScaffoldArgs(args);
360
+ const template = getTemplate(TEMPLATES, templateName);
1209
361
 
1210
362
  if (!template) {
1211
363
  throw new Error(`Unsupported template "${templateName}".`);
1212
364
  }
1213
365
 
1214
366
  const prerequisites = collectPrerequisites();
1215
- const summary = createSummary(template, targetDirectory, generatedInCurrentDirectory);
367
+ const summary = createSummary(
368
+ template,
369
+ targetDirectory,
370
+ generatedInCurrentDirectory
371
+ );
1216
372
  summary.options = options;
1217
- printPrerequisiteWarnings(prerequisites);
1218
- await scaffoldProject(template, targetDirectory, prerequisites);
1219
- writeProjectMetadata(template, targetDirectory);
1220
- summary.gitInit = prerequisites.git ? "completed" : "unavailable";
1221
- printSuccess(template, targetDirectory, generatedInCurrentDirectory);
373
+ printPrerequisiteWarnings(prerequisites, colors);
374
+ const localEnv = await scaffoldProject(
375
+ template,
376
+ targetDirectory,
377
+ prerequisites,
378
+ {
379
+ createLocalCredentials,
380
+ defaultGitignore: DEFAULT_GITIGNORE,
381
+ getTemplateDirectory: (templateId) =>
382
+ getTemplateDirectory(__dirname, templateId),
383
+ initializeGitRepository,
384
+ renderProgress,
385
+ toPackageName,
386
+ writeGeneratedLocalEnv
387
+ }
388
+ );
389
+ summary.localCredentials = localEnv.credentials;
390
+ writeProjectMetadata(template, targetDirectory, undefined, metadataOptions);
391
+ summary.gitInit = prerequisites.git ? 'completed' : 'unavailable';
392
+ printSuccess(template, targetDirectory, generatedInCurrentDirectory, colors);
1222
393
  await runPostGenerateActions(template, targetDirectory, summary);
1223
- writeProjectMetadata(template, targetDirectory, readProjectMetadata(targetDirectory));
1224
- printSummary(summary);
1225
- printNextSteps(summary);
394
+ writeProjectMetadata(
395
+ template,
396
+ targetDirectory,
397
+ readProjectMetadata(targetDirectory, METADATA_FILENAME),
398
+ metadataOptions
399
+ );
400
+ printSummary(summary, colors);
401
+ printNextSteps(summary, colors);
1226
402
  }
1227
403
 
1228
404
  main().catch((error) => {