@garyr/pt-cli 1.1.1 → 1.3.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.
@@ -3,21 +3,84 @@ import path from 'path';
3
3
  import inquirer from 'inquirer';
4
4
  import { loadConfig, sanitizePath } from '../config.js';
5
5
  import chalk from 'chalk';
6
- import { processCopyFiles } from '../substitute.js';
6
+ import { processCopyFiles, substituteVariables } from '../substitute.js';
7
7
  import { execSync } from 'child_process';
8
+ /**
9
+ * Recursively merges two arrays of FolderNodes, deduplicating matching folder names.
10
+ * Sub-children are recursively merged, and later nodes take precedence for info/is_file.
11
+ */
12
+ export function mergeFolderNodes(nodesA, nodesB) {
13
+ const map = new Map();
14
+ function cloneNode(node) {
15
+ return {
16
+ name: node.name,
17
+ info: node.info,
18
+ is_file: node.is_file,
19
+ children: node.children ? node.children.map(cloneNode) : undefined
20
+ };
21
+ }
22
+ for (const node of nodesA) {
23
+ map.set(node.name, cloneNode(node));
24
+ }
25
+ for (const node of nodesB) {
26
+ if (map.has(node.name)) {
27
+ const existing = map.get(node.name);
28
+ if (node.info) {
29
+ existing.info = node.info;
30
+ }
31
+ if (node.is_file !== undefined) {
32
+ existing.is_file = node.is_file;
33
+ }
34
+ if (node.children && node.children.length > 0) {
35
+ existing.children = mergeFolderNodes(existing.children || [], node.children);
36
+ }
37
+ }
38
+ else {
39
+ map.set(node.name, cloneNode(node));
40
+ }
41
+ }
42
+ return Array.from(map.values());
43
+ }
44
+ /**
45
+ * Merges variables across multiple templates.
46
+ * Variables with the same name are deduplicated; later templates override default values.
47
+ */
48
+ export function mergeVariables(templates) {
49
+ const varMap = new Map();
50
+ for (const { template } of templates) {
51
+ if (!template.variables)
52
+ continue;
53
+ for (const v of template.variables) {
54
+ if (varMap.has(v.name)) {
55
+ const existing = varMap.get(v.name);
56
+ varMap.set(v.name, {
57
+ name: v.name,
58
+ prompt: v.prompt || existing.prompt,
59
+ default: v.default !== undefined ? v.default : existing.default,
60
+ required: v.required !== undefined ? v.required : existing.required
61
+ });
62
+ }
63
+ else {
64
+ varMap.set(v.name, { ...v });
65
+ }
66
+ }
67
+ }
68
+ return Array.from(varMap.values());
69
+ }
70
+ /**
71
+ * Checks if a given destination path corresponds to a root-level readme.md file.
72
+ */
73
+ export function isRootReadme(filePath) {
74
+ const norm = sanitizePath(filePath).replace(/\\/g, '/');
75
+ const parts = norm.split('/').filter(Boolean);
76
+ return parts.length === 1 && /^readme\.md$/i.test(parts[0]);
77
+ }
8
78
  /**
9
79
  * Scan parent directories for .env files and parse their variables.
10
- * Returns a map of variable names to their values, supporting:
11
- * - KEY=VALUE format
12
- * - KEY="VALUE with spaces" format
13
- * - KEY='VALUE with spaces' format
14
- * - Comments (lines starting with #)
15
- * - Empty lines
16
80
  */
17
81
  function scanEnvForVariables(targetPath) {
18
82
  const envVars = {};
19
83
  let currentDir = path.resolve(targetPath);
20
- // Scan up to 5 parent directories for .env files
21
84
  const maxDepth = 5;
22
85
  for (let depth = 0; depth < maxDepth; depth++) {
23
86
  const envPath = path.join(currentDir, '.env');
@@ -27,16 +90,13 @@ function scanEnvForVariables(targetPath) {
27
90
  const lines = content.split('\n');
28
91
  for (const line of lines) {
29
92
  const trimmed = line.trim();
30
- // Skip empty lines and comments
31
93
  if (!trimmed || trimmed.startsWith('#')) {
32
94
  continue;
33
95
  }
34
- // Match KEY=VALUE patterns
35
96
  const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
36
97
  if (match) {
37
98
  const key = match[1];
38
99
  let value = match[2];
39
- // Remove surrounding quotes if present
40
100
  if ((value.startsWith('"') && value.endsWith('"')) ||
41
101
  (value.startsWith("'") && value.endsWith("'"))) {
42
102
  value = value.slice(1, -1);
@@ -46,78 +106,187 @@ function scanEnvForVariables(targetPath) {
46
106
  }
47
107
  }
48
108
  catch (err) {
49
- // Silently skip unreadable .env files
50
109
  continue;
51
110
  }
52
111
  }
53
- // Move to parent directory
54
112
  const parentDir = path.dirname(currentDir);
55
113
  if (parentDir === currentDir) {
56
- // Reached filesystem root
57
114
  break;
58
115
  }
59
116
  currentDir = parentDir;
60
117
  }
61
118
  return envVars;
62
119
  }
63
- export async function init(targetName, destPath, options = {}) {
120
+ export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined) {
64
121
  const config = loadConfig();
65
- let typeName = targetName;
66
- let dest = destPath;
67
- let template;
68
- if (options.file) {
69
- // If direct template file is specified, targetName could be the destPath if destPath is omitted
70
- if (typeName && !dest) {
71
- dest = typeName;
72
- typeName = undefined;
73
- }
74
- try {
75
- const fileContent = fs.readFileSync(options.file, 'utf-8');
76
- template = JSON.parse(fileContent);
77
- }
78
- catch (e) {
79
- console.error(chalk.red(`Error: Failed to read/parse template file "${options.file}": ${e.message}`));
80
- process.exit(1);
122
+ let rawTemplates = [];
123
+ let dest;
124
+ let options = {};
125
+ if (Array.isArray(targetOrArgs)) {
126
+ options = destPathOrOptions || {};
127
+ if (targetOrArgs.length === 0) {
128
+ rawTemplates = [];
129
+ dest = undefined;
81
130
  }
82
- if (!typeName) {
83
- typeName = template.name || 'custom-template';
131
+ else if (targetOrArgs.length === 1) {
132
+ if (options.file) {
133
+ rawTemplates = [options.file];
134
+ dest = targetOrArgs[0];
135
+ }
136
+ else {
137
+ rawTemplates = [targetOrArgs[0]];
138
+ dest = undefined;
139
+ }
140
+ }
141
+ else {
142
+ if (options.file) {
143
+ rawTemplates = [options.file, ...targetOrArgs.slice(0, -1)];
144
+ }
145
+ else {
146
+ rawTemplates = targetOrArgs.slice(0, -1);
147
+ }
148
+ dest = targetOrArgs[targetOrArgs.length - 1];
149
+ }
150
+ }
151
+ else if (typeof targetOrArgs === 'string') {
152
+ if (typeof destPathOrOptions === 'string') {
153
+ dest = destPathOrOptions;
154
+ options = optionsOrUndefined || {};
155
+ }
156
+ else if (destPathOrOptions !== undefined) {
157
+ dest = undefined;
158
+ options = destPathOrOptions || {};
159
+ }
160
+ else {
161
+ dest = undefined;
162
+ options = optionsOrUndefined || {};
163
+ }
164
+ if (options.file && !dest) {
165
+ dest = targetOrArgs;
166
+ rawTemplates = [options.file];
167
+ }
168
+ else {
169
+ rawTemplates = options.file ? [options.file, targetOrArgs] : [targetOrArgs];
84
170
  }
85
171
  }
86
172
  else {
87
- // If no name provided, list templates
88
- if (!typeName) {
89
- const names = Object.keys(config.templates);
90
- if (names.length === 0) {
91
- console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
92
- return;
93
- }
94
- if (options.yes) {
95
- console.error(chalk.red("No project type specified and running in non-interactive mode."));
96
- process.exit(1);
173
+ // targetOrArgs is undefined
174
+ if (typeof destPathOrOptions === 'string') {
175
+ dest = destPathOrOptions;
176
+ options = optionsOrUndefined || {};
177
+ }
178
+ else if (destPathOrOptions !== undefined) {
179
+ dest = undefined;
180
+ options = destPathOrOptions || {};
181
+ }
182
+ else {
183
+ dest = undefined;
184
+ options = optionsOrUndefined || {};
185
+ }
186
+ if (options.file) {
187
+ rawTemplates = [options.file];
188
+ }
189
+ }
190
+ // Interactive selection if no templates specified
191
+ if (rawTemplates.length === 0) {
192
+ const names = Object.keys(config.templates);
193
+ if (names.length === 0) {
194
+ const msg = "No templates found. Run 'pt learn <path>' first.";
195
+ if (options.json) {
196
+ console.error(JSON.stringify({ status: 'error', message: msg }));
97
197
  }
98
- const { selected } = await inquirer.prompt({
99
- type: 'list',
100
- name: 'selected',
101
- message: 'Select Project Type:',
102
- loop: false,
103
- theme: {
104
- icon: {
105
- cursor: chalk.green('[x] ')
106
- }
107
- },
108
- choices: names.map(n => ({ name: n, value: n }))
109
- });
110
- typeName = selected;
198
+ else {
199
+ console.log(chalk.red(msg));
200
+ }
201
+ process.exit(1);
111
202
  }
112
- template = config.templates[typeName];
113
- if (!template) {
114
- console.error(chalk.red(`Template "${typeName}" not found.`));
203
+ if (options.yes) {
204
+ const msg = "No project type specified and running in non-interactive mode.";
205
+ if (options.json) {
206
+ console.error(JSON.stringify({ status: 'error', message: msg }));
207
+ }
208
+ else {
209
+ console.error(chalk.red(msg));
210
+ }
115
211
  process.exit(1);
116
212
  }
213
+ const { selected } = await inquirer.prompt({
214
+ type: 'checkbox',
215
+ name: 'selected',
216
+ message: 'Select Project Type(s):',
217
+ loop: false,
218
+ validate: (answer) => (answer.length < 1 ? 'You must choose at least one template.' : true),
219
+ theme: {
220
+ icon: {
221
+ checked: chalk.green('[x] '),
222
+ unchecked: '[ ] '
223
+ }
224
+ },
225
+ choices: names.map(n => ({ name: n, value: n }))
226
+ });
227
+ rawTemplates = selected;
117
228
  }
229
+ // Load each template configuration
230
+ const loadedTemplates = [];
231
+ for (const item of rawTemplates) {
232
+ // Check if item is a local json file path or exists on disk
233
+ if (item.endsWith('.json') || fs.existsSync(item)) {
234
+ try {
235
+ const resolvedPath = path.resolve(item);
236
+ const fileContent = fs.readFileSync(resolvedPath, 'utf-8');
237
+ const parsed = JSON.parse(fileContent);
238
+ const name = parsed.name || path.basename(item, path.extname(item));
239
+ if (parsed.templateRoot && !path.isAbsolute(parsed.templateRoot)) {
240
+ parsed.templateRoot = path.resolve(path.dirname(resolvedPath), parsed.templateRoot);
241
+ }
242
+ else if (!parsed.templateRoot) {
243
+ parsed.templateRoot = path.dirname(resolvedPath);
244
+ }
245
+ loadedTemplates.push({
246
+ name,
247
+ template: parsed,
248
+ sourceFile: resolvedPath
249
+ });
250
+ }
251
+ catch (e) {
252
+ const msg = `Failed to read/parse template file "${item}": ${e.message}`;
253
+ if (options.json) {
254
+ console.error(JSON.stringify({ status: 'error', message: msg }));
255
+ }
256
+ else {
257
+ console.error(chalk.red(`Error: ${msg}`));
258
+ }
259
+ process.exit(1);
260
+ }
261
+ }
262
+ else {
263
+ const template = config.templates[item];
264
+ if (!template) {
265
+ const msg = `Template "${item}" not found.`;
266
+ if (options.json) {
267
+ console.error(JSON.stringify({ status: 'error', message: msg }));
268
+ }
269
+ else {
270
+ console.error(chalk.red(msg));
271
+ }
272
+ process.exit(1);
273
+ }
274
+ loadedTemplates.push({
275
+ name: item,
276
+ template: JSON.parse(JSON.stringify(template)) // Clone to prevent mutating config
277
+ });
278
+ }
279
+ }
280
+ // Prompt for destination if not provided
118
281
  if (!dest) {
119
282
  if (options.yes) {
120
- console.error(chalk.red("No destination path specified and running in non-interactive mode."));
283
+ const msg = "No destination path specified and running in non-interactive mode.";
284
+ if (options.json) {
285
+ console.error(JSON.stringify({ status: 'error', message: msg }));
286
+ }
287
+ else {
288
+ console.error(chalk.red(msg));
289
+ }
121
290
  process.exit(1);
122
291
  }
123
292
  const { name } = await inquirer.prompt({
@@ -129,21 +298,33 @@ export async function init(targetName, destPath, options = {}) {
129
298
  }
130
299
  const resolvedDest = path.resolve(dest);
131
300
  if (fs.existsSync(resolvedDest) && !options.dryRun) {
132
- console.error(chalk.red(`Error: Destination "${resolvedDest}" already exists.`));
301
+ const msg = `Destination "${resolvedDest}" already exists.`;
302
+ if (options.json) {
303
+ console.error(JSON.stringify({ status: 'error', message: msg }));
304
+ }
305
+ else {
306
+ console.error(chalk.red(`Error: ${msg}`));
307
+ }
133
308
  process.exit(1);
134
309
  }
135
- if (options.dryRun) {
136
- console.log(chalk.yellow(`\n[DRY RUN] Initializing project "${template.description}" at: ${resolvedDest}`));
137
- }
138
- else {
139
- console.log(chalk.cyan(`\nInitializing project "${template.description}" at: ${resolvedDest}`));
310
+ const templateNames = loadedTemplates.map(l => l.name);
311
+ const compositeDescription = loadedTemplates.length === 1
312
+ ? (loadedTemplates[0].template.description || '')
313
+ : loadedTemplates.map(l => `${l.name}: ${l.template.description || ''}`).join('; ');
314
+ if (!options.json) {
315
+ if (options.dryRun) {
316
+ console.log(chalk.yellow(`\n[DRY RUN] Initializing project "${compositeDescription}" at: ${resolvedDest}`));
317
+ }
318
+ else {
319
+ console.log(chalk.cyan(`\nInitializing project "${compositeDescription}" at: ${resolvedDest}`));
320
+ }
140
321
  }
141
- // Handle Variables
322
+ // Merge Variables
323
+ const mergedVarsDef = mergeVariables(loadedTemplates);
142
324
  let variables = {};
143
- if (template.variables && template.variables.length > 0) {
325
+ if (mergedVarsDef.length > 0) {
144
326
  // Scan parent directories for .env files and pre-fill variables
145
327
  const envVars = scanEnvForVariables(resolvedDest);
146
- // Merge .env variables into variables (with lower priority than --vars)
147
328
  if (Object.keys(envVars).length > 0) {
148
329
  for (const [key, value] of Object.entries(envVars)) {
149
330
  if (!variables[key]) {
@@ -152,7 +333,6 @@ export async function init(targetName, destPath, options = {}) {
152
333
  }
153
334
  }
154
335
  if (options.vars) {
155
- // Parse --vars "key=val,key2=val2"
156
336
  const pairs = options.vars.split(',').map((p) => p.trim());
157
337
  for (const pair of pairs) {
158
338
  const [k, ...v] = pair.split('=');
@@ -162,8 +342,7 @@ export async function init(targetName, destPath, options = {}) {
162
342
  }
163
343
  }
164
344
  if (!options.yes) {
165
- // Prompt for any missing variables
166
- for (const v of template.variables) {
345
+ for (const v of mergedVarsDef) {
167
346
  if (!variables[v.name]) {
168
347
  const answer = await inquirer.prompt({
169
348
  type: 'input',
@@ -176,11 +355,16 @@ export async function init(targetName, destPath, options = {}) {
176
355
  }
177
356
  }
178
357
  else {
179
- // Non-interactive mode: check required
180
- for (const v of template.variables) {
358
+ for (const v of mergedVarsDef) {
181
359
  if (!variables[v.name]) {
182
360
  if (v.required) {
183
- console.error(chalk.red(`Error: Variable "${v.name}" is required but was not provided in non-interactive mode. Use --vars ${v.name}=value`));
361
+ const msg = `Variable "${v.name}" is required but was not provided in non-interactive mode. Use --vars ${v.name}=value`;
362
+ if (options.json) {
363
+ console.error(JSON.stringify({ status: 'error', message: msg }));
364
+ }
365
+ else {
366
+ console.error(chalk.red(`Error: ${msg}`));
367
+ }
184
368
  process.exit(1);
185
369
  }
186
370
  else {
@@ -190,135 +374,238 @@ export async function init(targetName, destPath, options = {}) {
190
374
  }
191
375
  }
192
376
  }
193
- // 1. Create structure
194
- createStructure(resolvedDest, template.folders, options.dryRun);
195
- // Check if templateRoot exists (if it's defined)
196
- const templateRootExists = template.templateRoot && fs.existsSync(template.templateRoot);
197
- if (template.templateRoot && !templateRootExists) {
198
- console.warn(chalk.yellow(`\nWarning: Template source directory not found: ${template.templateRoot}`));
199
- console.warn(chalk.gray("Folder structure created, but files/boilerplate will be skipped."));
377
+ // 1. Create structure (deep merge folders across all templates)
378
+ let mergedFolders = [];
379
+ for (const { template } of loadedTemplates) {
380
+ if (template.folders) {
381
+ mergedFolders = mergeFolderNodes(mergedFolders, template.folders);
382
+ }
200
383
  }
201
- // 2. Process copy_files
202
- if (template.copy_files && templateRootExists) {
203
- if (options.dryRun)
204
- console.log(chalk.yellow("[DRY RUN] Processing copy_files..."));
205
- else
206
- console.log(chalk.cyan("Processing copy_files..."));
207
- await processCopyFiles(template.templateRoot, resolvedDest, template, variables, options.dryRun);
384
+ createStructure(resolvedDest, mergedFolders, options.dryRun, options.json);
385
+ // 2. Readme renaming logic
386
+ const templatesWithReadme = [];
387
+ for (const lt of loadedTemplates) {
388
+ const hasReadme = (lt.template.copy_files || []).some(cf => isRootReadme(cf.dest || cf.src));
389
+ if (hasReadme) {
390
+ templatesWithReadme.push(lt);
391
+ }
208
392
  }
209
- // 3. Process post_copy (executable scripts)
210
- if (template.post_copy && templateRootExists) {
211
- if (options.dryRun)
212
- console.log(chalk.yellow("[DRY RUN] Processing post_copy..."));
213
- else
214
- console.log(chalk.cyan("Processing post_copy..."));
215
- for (const file of template.post_copy) {
216
- const srcPath = path.join(template.templateRoot, file.src);
217
- const destPath = path.join(resolvedDest, sanitizePath(file.dest || file.src));
218
- if (fs.existsSync(srcPath)) {
219
- if (options.dryRun) {
220
- console.log(chalk.gray(` [DRY RUN] Would copy ${file.src}${file.dest || file.src}`));
221
- console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
222
- continue;
223
- }
224
- let fileContent = fs.readFileSync(srcPath, 'utf-8');
225
- // Substitute variables in post_copy files if template has variables
226
- if (template.variables && template.variables.length > 0) {
227
- const { substituteVariables } = await import('../substitute.js');
228
- fileContent = substituteVariables(fileContent, variables);
393
+ const createdReadmes = [];
394
+ if (templatesWithReadme.length > 1) {
395
+ // Multiple templates have a root readme -> rename based on origin template name while preserving case
396
+ for (const lt of templatesWithReadme) {
397
+ for (const cf of lt.template.copy_files || []) {
398
+ const targetDest = cf.dest || cf.src;
399
+ if (isRootReadme(targetDest)) {
400
+ const baseName = path.basename(targetDest);
401
+ const match = baseName.match(/^(readme)(.*)(\.md)$/i);
402
+ let newDest;
403
+ if (match) {
404
+ newDest = `${match[1]}${match[2]}_${lt.name}${match[3]}`;
405
+ }
406
+ else {
407
+ newDest = `readme_${lt.name}.md`;
408
+ }
409
+ cf.dest = newDest;
410
+ createdReadmes.push(newDest);
229
411
  }
230
- const destDir = path.dirname(destPath);
231
- fs.mkdirSync(destDir, { recursive: true });
232
- fs.writeFileSync(destPath, fileContent);
233
- // post_copy files are executables by definition — always chmod
234
- try {
235
- // Check if source had execute permissions, otherwise default to 0o755
236
- const srcStat = fs.statSync(srcPath);
237
- fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
412
+ }
413
+ }
414
+ }
415
+ else if (templatesWithReadme.length === 1) {
416
+ // Exactly one template has a root readme -> keep standard name
417
+ for (const cf of templatesWithReadme[0].template.copy_files || []) {
418
+ const targetDest = cf.dest || cf.src;
419
+ if (isRootReadme(targetDest)) {
420
+ createdReadmes.push(targetDest);
421
+ }
422
+ }
423
+ }
424
+ // 3. Process copy_files for each template
425
+ const collisionMode = options.collision || 'overwrite';
426
+ for (const lt of loadedTemplates) {
427
+ const template = lt.template;
428
+ const templateRootExists = template.templateRoot && fs.existsSync(template.templateRoot);
429
+ if (template.templateRoot && !templateRootExists) {
430
+ if (!options.json) {
431
+ console.warn(chalk.yellow(`\nWarning: Template source directory not found: ${template.templateRoot}`));
432
+ console.warn(chalk.gray("Folder structure created, but files/boilerplate will be skipped."));
433
+ }
434
+ }
435
+ if (template.copy_files && templateRootExists) {
436
+ if (!options.json) {
437
+ if (options.dryRun)
438
+ console.log(chalk.yellow(`[DRY RUN] Processing copy_files for ${lt.name}...`));
439
+ else
440
+ console.log(chalk.cyan(`Processing copy_files for ${lt.name}...`));
441
+ }
442
+ await processCopyFiles(template.templateRoot, resolvedDest, template, variables, options.dryRun, collisionMode, options.json);
443
+ }
444
+ // 4. Process post_copy (executable scripts)
445
+ if (template.post_copy && templateRootExists) {
446
+ if (!options.json) {
447
+ if (options.dryRun)
448
+ console.log(chalk.yellow(`[DRY RUN] Processing post_copy for ${lt.name}...`));
449
+ else
450
+ console.log(chalk.cyan(`Processing post_copy for ${lt.name}...`));
451
+ }
452
+ for (const file of template.post_copy) {
453
+ const srcPath = path.join(template.templateRoot, file.src);
454
+ const destPath = path.join(resolvedDest, sanitizePath(file.dest || file.src));
455
+ if (fs.existsSync(srcPath)) {
456
+ if (options.dryRun) {
457
+ if (!options.json) {
458
+ console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
459
+ console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
460
+ }
461
+ continue;
462
+ }
463
+ if (collisionMode === 'newest' && fs.existsSync(destPath)) {
464
+ const destStat = fs.statSync(destPath);
465
+ const srcStat = fs.statSync(srcPath);
466
+ if (destStat.mtimeMs > srcStat.mtimeMs) {
467
+ if (!options.json) {
468
+ console.log(chalk.yellow(` [COLLISION] Destination is newer, keeping ${file.dest || file.src}`));
469
+ }
470
+ continue;
471
+ }
472
+ }
473
+ let fileContent = fs.readFileSync(srcPath, 'utf-8');
474
+ if (mergedVarsDef.length > 0) {
475
+ fileContent = substituteVariables(fileContent, variables);
476
+ }
477
+ const destDir = path.dirname(destPath);
478
+ fs.mkdirSync(destDir, { recursive: true });
479
+ fs.writeFileSync(destPath, fileContent);
480
+ try {
481
+ const srcStat = fs.statSync(srcPath);
482
+ fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
483
+ }
484
+ catch (e) { }
485
+ if (!options.json)
486
+ console.log(chalk.green(" ✓ " + (file.dest || file.src)));
238
487
  }
239
- catch (e) {
240
- // chmod not available (Windows)
488
+ else if (!options.json) {
489
+ console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
241
490
  }
242
- console.log(chalk.green(" ✓ " + (file.dest || file.src)));
243
- }
244
- else {
245
- console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
246
491
  }
247
492
  }
248
493
  }
249
- // Write .info.md
494
+ // 5. Write .info.md
250
495
  if (!options.dryRun) {
251
- const infoContent = `# ${typeName}\n\n${template.description || ''}\n`;
496
+ let infoContent = '';
497
+ if (loadedTemplates.length === 1) {
498
+ infoContent = `# ${loadedTemplates[0].name}\n\n${loadedTemplates[0].template.description || ''}\n`;
499
+ }
500
+ else {
501
+ infoContent = `# ${templateNames.join(', ')}\n\n`;
502
+ for (const lt of loadedTemplates) {
503
+ infoContent += `## ${lt.name}\n${lt.template.description || ''}\n\n`;
504
+ }
505
+ }
252
506
  fs.writeFileSync(path.join(resolvedDest, '.info.md'), infoContent);
253
507
  }
254
- else {
508
+ else if (!options.json) {
255
509
  console.log(chalk.gray(` [DRY RUN] Would create .info.md`));
256
510
  }
257
- // Use template post_config tasks
258
- const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName) || [];
511
+ // 6. Collect and deduplicate post_config tasks from all templates
512
+ // Key: command|description -> { task, templates: string[], _id: string }
513
+ const taskMap = new Map();
514
+ let taskIdCounter = 0;
515
+ for (const lt of loadedTemplates) {
516
+ if (lt.template.post_config) {
517
+ for (const t of lt.template.post_config) {
518
+ if (!t.type || t.type === lt.name) {
519
+ const key = `${t.command || t.script || ''}|${t.description || ''}`;
520
+ if (taskMap.has(key)) {
521
+ taskMap.get(key).templates.push(lt.name);
522
+ }
523
+ else {
524
+ taskMap.set(key, {
525
+ ...t,
526
+ templates: [lt.name],
527
+ _id: `task_${taskIdCounter++}`
528
+ });
529
+ }
530
+ }
531
+ }
532
+ }
533
+ }
534
+ const allTasks = Array.from(taskMap.values());
259
535
  if (allTasks.length > 0 && !options.skipPostConfig) {
260
- // SECURITY CHECK: Validate template safety before running post_config tasks
536
+ // SECURITY CHECK: Validate template safety (aggregate across all templates)
261
537
  const { validateTemplateSecurity } = await import('../safety.js');
262
- const { valid, errors, warnings } = validateTemplateSecurity(template);
263
- if (!valid) {
264
- console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
538
+ // Collect all errors and warnings from all templates first
539
+ const allErrors = [];
540
+ const allWarnings = [];
541
+ for (const lt of loadedTemplates) {
542
+ const { valid, errors, warnings } = validateTemplateSecurity(lt.template);
265
543
  for (const err of errors) {
266
- console.error(chalk.red(` - ${err}`));
544
+ allErrors.push({ template: lt.name, error: err });
545
+ }
546
+ for (const warn of warnings) {
547
+ allWarnings.push({ template: lt.name, warning: warn });
548
+ }
549
+ }
550
+ // Handle errors - any blocked command aborts everything
551
+ if (allErrors.length > 0) {
552
+ if (!options.json) {
553
+ console.error(chalk.red(`\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:`));
554
+ for (const { template, error } of allErrors) {
555
+ console.error(chalk.red(` [${template}] ${error}`));
556
+ }
267
557
  }
268
558
  process.exit(1);
269
559
  }
270
- if (warnings.length > 0) {
271
- console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
272
- for (const warn of warnings) {
273
- console.warn(chalk.yellow(` - ${warn}`));
560
+ // Handle warnings - single aggregated prompt for all templates
561
+ if (allWarnings.length > 0) {
562
+ if (!options.json) {
563
+ console.warn(chalk.yellow(`\n⚠️ SECURITY WARNING: Post-config tasks contain dangerous commands:`));
564
+ for (const { template, warning } of allWarnings) {
565
+ console.warn(chalk.yellow(` [${template}] ${warning}`));
566
+ }
274
567
  }
275
568
  if (!options.yes) {
276
569
  const { proceed } = await inquirer.prompt({
277
570
  type: 'confirm',
278
571
  name: 'proceed',
279
- message: chalk.red('Are you sure you want to run these post-config tasks?'),
572
+ message: chalk.red(`Security warnings found in ${new Set(allWarnings.map(w => w.template)).size} template(s). Run post-config tasks anyway?`),
280
573
  default: false
281
574
  });
282
575
  if (!proceed) {
283
- console.log(chalk.yellow("Post-config tasks aborted by user."));
576
+ if (!options.json)
577
+ console.log(chalk.yellow("Post-config tasks aborted by user."));
284
578
  return;
285
579
  }
286
580
  }
287
- else {
581
+ else if (!options.json) {
288
582
  console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
289
583
  }
290
584
  }
291
- // Determine which tasks to include
292
- let selectedTaskNames = [];
293
- if (options.skipPostConfig) {
294
- // Skip entirely
295
- selectedTaskNames = [];
296
- }
297
- else if (options.dryRun) {
298
- // In dry-run, select all (for display)
299
- selectedTaskNames = allTasks.map(t => t.command || `./${t.script}` || '');
300
- console.log(chalk.yellow(`\n[DRY RUN] Applicable post-config tasks:`));
301
- for (const t of allTasks) {
302
- const desc = t.description ? ` (${t.description})` : '';
303
- console.log(chalk.gray(` [template] - ${t.command || `./${t.script}`}${desc}`));
585
+ let selectedTaskIds = [];
586
+ if (options.dryRun) {
587
+ selectedTaskIds = allTasks.map(t => t._id);
588
+ if (!options.json) {
589
+ console.log(chalk.yellow(`\n[DRY RUN] Applicable post-config tasks:`));
590
+ for (const t of allTasks) {
591
+ const desc = t.description ? ` (${t.description})` : '';
592
+ const templatesNote = t.templates.length > 1 ? ` [${t.templates.join(', ')}]` : ` [${t.templates[0]}]`;
593
+ console.log(chalk.gray(` ${t.command || `./${t.script}`}${desc}${templatesNote}`));
594
+ }
304
595
  }
305
596
  }
306
597
  else if (options.yes) {
307
- // All tasks selected
308
- selectedTaskNames = allTasks.map(t => t.command || `./${t.script}` || '');
309
- }
310
- else if (allTasks.length === 0) {
311
- selectedTaskNames = [];
598
+ selectedTaskIds = allTasks.map(t => t._id);
312
599
  }
313
600
  else {
314
- // Checkbox prompt
315
601
  const choices = [];
316
602
  for (const t of allTasks) {
317
603
  const cmd = t.command || `./${t.script}` || '(no command)';
318
604
  const desc = t.description ? ` (${t.description})` : '';
605
+ const templatesNote = t.templates.length > 1 ? ` [${t.templates.join(', ')}]` : ` [${t.templates[0]}]`;
319
606
  choices.push({
320
- name: `${cmd}${desc}`,
321
- value: cmd,
607
+ name: `${cmd}${desc}${templatesNote}`,
608
+ value: t._id,
322
609
  checked: true
323
610
  });
324
611
  }
@@ -335,14 +622,12 @@ export async function init(targetName, destPath, options = {}) {
335
622
  },
336
623
  choices
337
624
  });
338
- selectedTaskNames = response.selected || [];
625
+ selectedTaskIds = response.selected || [];
339
626
  }
340
- // Write post_config scripts for selected tasks
341
- if (selectedTaskNames.length > 0 && !options.dryRun) {
627
+ if (selectedTaskIds.length > 0 && !options.dryRun) {
342
628
  let bashContent = '#!/bin/bash\n# Auto-generated post_config script\n\n';
343
629
  let batContent = '@echo off\n:: Auto-generated post_config script\n\n';
344
630
  for (const t of allTasks) {
345
- // Determine the actual command/script to use
346
631
  let cmd = '';
347
632
  if (t.command) {
348
633
  cmd = t.command;
@@ -350,12 +635,10 @@ export async function init(targetName, destPath, options = {}) {
350
635
  else if (t.script) {
351
636
  cmd = `./${t.script}`;
352
637
  }
353
- // Match against selected names (use command if available, else script)
354
- const taskKey = t.command || (t.script ? `./${t.script}` : '');
355
- if (selectedTaskNames.includes(taskKey)) {
638
+ if (selectedTaskIds.includes(t._id)) {
356
639
  if (cmd) {
357
- bashContent += `echo "Running: ${t.description || taskKey}"\n${cmd}\n`;
358
- batContent += `echo Running: ${t.description || taskKey}\n${cmd}\n`;
640
+ bashContent += `echo "Running: ${t.description || cmd}"\n${cmd}\n`;
641
+ batContent += `echo Running: ${t.description || cmd}\n${cmd}\n`;
359
642
  }
360
643
  }
361
644
  }
@@ -365,49 +648,61 @@ export async function init(targetName, destPath, options = {}) {
365
648
  }
366
649
  catch (e) { }
367
650
  fs.writeFileSync(path.join(resolvedDest, 'post_config.bat'), batContent);
368
- // Execute the appropriate script
369
- console.log(chalk.cyan("\nExecuting post-config tasks..."));
651
+ if (!options.json)
652
+ console.log(chalk.cyan("\nExecuting post-config tasks..."));
370
653
  try {
371
654
  const scriptCmd = process.platform === 'win32' ? 'post_config.bat' : './post_config.sh';
372
655
  execSync(scriptCmd, {
373
656
  cwd: resolvedDest,
374
- stdio: 'inherit'
657
+ stdio: options.json ? 'ignore' : 'inherit'
375
658
  });
376
659
  }
377
660
  catch (e) {
378
- console.error(chalk.red("\nError: Some post-config tasks failed. Check the output above."));
661
+ if (!options.json)
662
+ console.error(chalk.red("\nError: Some post-config tasks failed. Check the output above."));
379
663
  }
380
664
  }
381
665
  }
382
- if (options.dryRun) {
666
+ if (options.json) {
667
+ const result = {
668
+ status: 'success',
669
+ dryRun: !!options.dryRun,
670
+ dest: resolvedDest,
671
+ templates: templateNames,
672
+ variables,
673
+ readmes: createdReadmes
674
+ };
675
+ console.log(JSON.stringify(result, null, 2));
676
+ }
677
+ else if (options.dryRun) {
383
678
  console.log(chalk.yellow(`\n[DRY RUN] Project initialization preview complete.`));
384
679
  }
385
680
  else {
386
681
  console.log(chalk.green(`\n✓ Project created successfully.`));
387
682
  }
388
683
  }
389
- function createStructure(dirPath, folders, dryRun = false) {
684
+ function createStructure(dirPath, folders, dryRun = false, silent = false) {
390
685
  for (const folder of folders) {
391
686
  const fullDirPath = path.join(dirPath, sanitizePath(folder.name));
392
687
  if (dryRun) {
393
- console.log(chalk.gray(` [DRY RUN] Would create directory: ${fullDirPath}`));
688
+ if (!silent)
689
+ console.log(chalk.gray(` [DRY RUN] Would create directory: ${fullDirPath}`));
394
690
  }
395
691
  else {
396
692
  fs.mkdirSync(fullDirPath, { recursive: true });
397
693
  }
398
- // Create .info.md if content exists
399
694
  if (folder.info) {
400
695
  const infoPath = path.join(fullDirPath, '.info.md');
401
696
  if (dryRun) {
402
- console.log(chalk.gray(` [DRY RUN] Would create info file: ${infoPath}`));
697
+ if (!silent)
698
+ console.log(chalk.gray(` [DRY RUN] Would create info file: ${infoPath}`));
403
699
  }
404
700
  else {
405
701
  fs.writeFileSync(infoPath, folder.info);
406
702
  }
407
703
  }
408
- // Recurse children
409
704
  if (folder.children && folder.children.length > 0) {
410
- createStructure(fullDirPath, folder.children, dryRun);
705
+ createStructure(fullDirPath, folder.children, dryRun, silent);
411
706
  }
412
707
  }
413
708
  }