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