@garyr/pt-cli 0.40.0 → 0.41.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,35 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import inquirer from 'inquirer';
4
- import { loadConfig, saveConfig, FolderNode, TemplateConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, TemplateVariable, CopyFileEntry, PostConfigTask, getDefaultPostConfig } from '../config.js';
4
+ import { loadConfig, saveConfig, FolderNode, TemplateConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, PostCopyFile, TemplateVariable, CopyFileEntry, PostConfigTask, getDefaultPostConfig } from '../config.js';
5
5
  import chalk from 'chalk';
6
6
  import { downloadAndExtract } from '../remote.js';
7
+ import {
8
+ extractStructure,
9
+ findVariablesInFiles,
10
+ isExecutable,
11
+ parseInfoFile,
12
+ loadJsonTemplateConfig,
13
+ getRootEntries,
14
+ parsePostConfigScript,
15
+ mergePostConfigTasks,
16
+ mergePostCopyFiles,
17
+ buildCopyFiles,
18
+ promptTemplateName,
19
+ promptDescription,
20
+ promptAdditionalVariables,
21
+ promptNewVariables,
22
+ promptGlobalVariables,
23
+ printNoNewVariables,
24
+ promptNewFolders,
25
+ printNoNewFolders,
26
+ printNewFiles,
27
+ promptNewFiles,
28
+ promptRootFiles,
29
+ promptStructureFolders,
30
+ promptCopyFolders,
31
+ promptPostConfigTasks
32
+ } from './template-utils.js';
7
33
 
8
34
  export interface UpdateOptions {
9
35
  ignore?: string;
@@ -14,90 +40,9 @@ export interface UpdateOptions {
14
40
  noDiff?: boolean;
15
41
  }
16
42
 
17
- // Additive mode functions - only add new items, never remove existing
18
- interface NewStructure {
19
- added: FolderNode[];
20
- }
21
-
22
- interface NewFiles {
23
- newFiles: string[];
24
- }
25
-
26
- interface NewVariables {
27
- newVariables: TemplateVariable[];
28
- }
29
-
30
- function getNewFolders(storedStructure: FolderNode[], targetPath: string, ignorePatterns: string[]): NewStructure {
31
- const added: FolderNode[] = [];
32
-
33
- const getStructureMap = (nodes: FolderNode[]): Map<string, FolderNode> => {
34
- const map = new Map<string, FolderNode>();
35
- for (const node of nodes) {
36
- map.set(node.name, node);
37
- }
38
- return map;
39
- };
40
-
41
- const storedMap = getStructureMap(storedStructure);
42
- const targetStructure = extractStructure(targetPath, targetPath, ignorePatterns);
43
- const targetMap = getStructureMap(targetStructure);
44
-
45
- // Find only new folders
46
- for (const [name, targetNode] of targetMap) {
47
- if (!storedMap.has(name)) {
48
- added.push(targetNode);
49
- }
50
- }
51
-
52
- return { added };
53
- }
54
-
55
- function getNewFiles(storedTemplate: TemplateConfig, targetPath: string): NewFiles {
56
- const newFiles: string[] = [];
57
-
58
- // Get current files in target
59
- const currentFiles = fs.readdirSync(targetPath, { withFileTypes: true })
60
- .filter(e => e.isFile())
61
- .map(e => e.name);
62
-
63
- // Get files from stored template
64
- const storedFiles = storedTemplate.copy_files?.map(f => f.src) || [];
65
-
66
- // Find only new files
67
- for (const file of currentFiles) {
68
- if (!storedFiles.includes(file)) {
69
- newFiles.push(file);
70
- }
71
- }
72
-
73
- return { newFiles };
74
- }
75
-
76
- function getNewVariables(storedVariables: TemplateVariable[], targetPath: string): NewVariables {
77
- const newVariables: TemplateVariable[] = [];
78
-
79
- // Get variables from target directory
80
- const targetVars = findVariablesInFiles(targetPath, targetPath);
81
- const storedVarMap = new Set(storedVariables.map(v => v.name));
82
-
83
- // Find only new variables
84
- for (const varName of targetVars) {
85
- if (!storedVarMap.has(varName)) {
86
- newVariables.push({
87
- name: varName,
88
- prompt: `Enter ${varName}:`,
89
- required: true
90
- });
91
- }
92
- }
93
-
94
- return { newVariables };
95
- }
96
-
97
43
  export async function update(sourcePath: string, templateName: string, options: UpdateOptions = {}): Promise<void> {
98
- // Determine if additive mode is disabled
99
44
  const isFullMode = options.noDiff;
100
-
45
+
101
46
  let resolvedPath: string;
102
47
 
103
48
  // Phase 1: Remote Check
@@ -122,45 +67,30 @@ export async function update(sourcePath: string, templateName: string, options:
122
67
  }
123
68
 
124
69
  const config = loadConfig();
70
+ const existingNames = getTemplateNames(config);
71
+
125
72
  if (!config.templates[templateName]) {
126
73
  console.error(chalk.red(`Template "${templateName}" not found.`));
127
74
  process.exit(1);
128
75
  }
129
76
 
130
- // Check for template configuration JSON file (.pt-template.json or template.json)
131
- let fileTemplateConfig: Partial<TemplateConfig> & { name?: string } = {};
132
- const jsonConfigPaths = [
133
- path.join(resolvedPath, '.pt-template.json'),
134
- path.join(resolvedPath, 'template.json')
135
- ];
136
- for (const jPath of jsonConfigPaths) {
137
- if (fs.existsSync(jPath)) {
138
- try {
139
- const fileContent = fs.readFileSync(jPath, 'utf-8');
140
- fileTemplateConfig = JSON.parse(fileContent);
141
- if (!options.json) console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
77
+ // Check for template configuration JSON file
78
+ const fileTemplateConfig = loadJsonTemplateConfig(resolvedPath);
79
+ if (fileTemplateConfig && !options.json) {
80
+ const jsonConfigPaths = [
81
+ path.join(resolvedPath, '.pt-template.json'),
82
+ path.join(resolvedPath, 'template.json')
83
+ ];
84
+ for (const jPath of jsonConfigPaths) {
85
+ if (fs.existsSync(jPath)) {
86
+ console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
142
87
  break;
143
- } catch (e) {
144
- console.warn(chalk.yellow(`Warning: Failed to parse ${path.basename(jPath)}: ${(e as Error).message}`));
145
88
  }
146
89
  }
147
90
  }
148
91
 
149
92
  // Check for .info.md
150
- let infoName = '';
151
- let infoDesc = '';
152
- const infoPath = path.join(resolvedPath, '.info.md');
153
- if (fs.existsSync(infoPath)) {
154
- const infoContent = fs.readFileSync(infoPath, 'utf-8');
155
- const lines = infoContent.split('\n');
156
- for (const line of lines) {
157
- if (line.startsWith('# ')) {
158
- infoName = line.substring(2).trim();
159
- } else if (line.trim() !== '' && !infoDesc && !line.startsWith('#')) {
160
- infoDesc = line.trim();
161
- }
162
- }
163
- }
93
+ const { name: infoName, description: infoDesc } = parseInfoFile(path.join(resolvedPath, '.info.md'));
164
94
 
165
95
  let description = config.templates[templateName].description || '';
166
96
  let templateRoot = config.templates[templateName].templateRoot || resolvedPath;
@@ -207,21 +137,19 @@ export async function update(sourcePath: string, templateName: string, options:
207
137
  }
208
138
 
209
139
  let variables: TemplateVariable[] = [];
210
-
140
+
211
141
  // During updates, merge existing template variables with JSON file variables
212
142
  if (config.templates[templateName].variables) {
213
143
  variables = [...config.templates[templateName].variables];
214
144
  }
215
-
145
+
216
146
  // Then add JSON variables (overwrite/update existing ones with same name)
217
147
  if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
218
148
  for (const v of fileTemplateConfig.variables) {
219
149
  const existingIndex = variables.findIndex(existing => existing.name === v.name);
220
150
  if (existingIndex !== -1) {
221
- // Update existing variable with JSON values (but preserve other fields)
222
151
  variables[existingIndex] = { ...variables[existingIndex], ...v };
223
152
  } else {
224
- // Add new variable
225
153
  variables.push({ ...v });
226
154
  }
227
155
  }
@@ -241,51 +169,32 @@ export async function update(sourcePath: string, templateName: string, options:
241
169
  // Variable handling - additive mode
242
170
  if (!isFullMode) {
243
171
  // Additive mode: only present new variables for selection
244
- const variableDiff = getNewVariables(config.templates[templateName].variables || [], resolvedPath);
172
+ const existingVarNames = new Set(config.templates[templateName].variables?.map(v => v.name) || []);
173
+ const newVars = variables.filter(v => !existingVarNames.has(v.name));
174
+
175
+ if (newVars.length > 0) {
176
+ console.log(chalk.cyan(`\n📊 New Variables:`));
177
+ console.log(chalk.green(` + ${newVars.length} new variable(s): ${newVars.map(v => v.name).join(', ')}`));
178
+
179
+ const selectedNewVars = await promptNewVariables(newVars, options);
180
+ variables.push(...selectedNewVars);
181
+ } else {
182
+ printNoNewVariables();
183
+ }
245
184
 
246
185
  // Also include default/global variables that are not already in the template
247
186
  const globalVarsToPrompt: TemplateVariable[] = [];
248
187
  if (config.variables && Array.isArray(config.variables)) {
249
188
  for (const v of config.variables) {
250
- if (!variables.some(existing => existing.name === v.name) &&
251
- !variableDiff.newVariables.some(existing => existing.name === v.name)) {
189
+ if (!variables.some(existing => existing.name === v.name)) {
252
190
  globalVarsToPrompt.push({ ...v });
253
191
  }
254
192
  }
255
193
  }
256
194
 
257
- const combinedNewVars = [...variableDiff.newVariables, ...globalVarsToPrompt];
258
-
259
- if (combinedNewVars.length > 0) {
260
- console.log(chalk.cyan(`\n📊 New Variables:`));
261
- console.log(chalk.green(` + ${combinedNewVars.length} new variable(s): ${combinedNewVars.map(v => v.name).join(', ')}`));
262
-
263
- // Show interactive checkbox for new variables
264
- if (!options.yes && !options.json) {
265
- const { selectedVars } = await inquirer.prompt({
266
- type: 'checkbox',
267
- name: 'selectedVars',
268
- message: 'Select variables to include (space to toggle):',
269
- loop: false,
270
- theme: {
271
- icon: {
272
- checked: chalk.green('[x] '),
273
- unchecked: '[ ] ',
274
- }
275
- },
276
- choices: combinedNewVars.map(v => ({
277
- name: v.name,
278
- checked: true // Auto-select by default
279
- }))
280
- });
281
- // Only add selected variables
282
- variables.push(...combinedNewVars.filter(v => selectedVars.includes(v.name)));
283
- } else {
284
- // In --yes or --json mode, auto-add all
285
- variables.push(...combinedNewVars);
286
- }
287
- } else {
288
- console.log(chalk.cyan("No new variables detected"));
195
+ if (globalVarsToPrompt.length > 0) {
196
+ const selectedGlobals = await promptGlobalVariables(globalVarsToPrompt, options);
197
+ variables.push(...selectedGlobals);
289
198
  }
290
199
  } else {
291
200
  // Full mode: original behavior with optional default/global variables prompt
@@ -299,99 +208,34 @@ export async function update(sourcePath: string, templateName: string, options:
299
208
  }
300
209
 
301
210
  if (globalVarsToPrompt.length > 0) {
302
- if (!options.yes && !options.json) {
303
- const { selectedGlobals } = await inquirer.prompt({
304
- type: 'checkbox',
305
- name: 'selectedGlobals',
306
- message: 'Select default/global variables to include (space to toggle):',
307
- loop: false,
308
- theme: {
309
- icon: {
310
- checked: chalk.green('[x] '),
311
- unchecked: '[ ] ',
312
- }
313
- },
314
- choices: globalVarsToPrompt.map(v => ({ name: v.name, checked: true }))
315
- });
316
- variables.push(...globalVarsToPrompt.filter(v => selectedGlobals.includes(v.name)));
317
- } else {
318
- variables.push(...globalVarsToPrompt);
319
- }
211
+ const selectedGlobals = await promptGlobalVariables(globalVarsToPrompt, options);
212
+ variables.push(...selectedGlobals);
320
213
  }
321
214
 
322
- let hasMoreVariables = false;
323
- if (!options.yes && !options.json) {
324
- const message = variables.length > 0
325
- ? `Detected/Existing variables: ${variables.map(v => v.name).join(', ')}. Define more?`
326
- : 'Define template variables (e.g., client_name, project_type)?';
327
-
328
- const response = await inquirer.prompt({
329
- type: 'confirm',
330
- name: 'hasMoreVariables',
331
- message: message,
332
- default: false
333
- });
334
- hasMoreVariables = response.hasMoreVariables;
335
- }
336
-
337
- if (hasMoreVariables) {
338
- const { variableDefs } = await inquirer.prompt({
339
- type: 'input',
340
- name: 'variableDefs',
341
- message: 'Define additional variables as comma-separated names:',
342
- });
343
- if (variableDefs) {
344
- const additionalVars = (variableDefs as string).split(',').map((v: string) => v.trim()).filter(Boolean);
345
- for (const v of additionalVars) {
346
- if (!variables.some(existing => existing.name === v)) {
347
- variables.push({
348
- name: v,
349
- prompt: `Enter ${v}:`,
350
- required: true
351
- });
352
- }
353
- }
354
- }
355
- }
215
+ const additionalVars = await promptAdditionalVariables(variables, options);
216
+ variables.push(...additionalVars);
356
217
  }
357
218
 
358
219
  // 1. Structure (skeleton) - Additive mode
359
220
  let folders: FolderNode[] = [];
360
221
  if (!isFullMode) {
361
222
  // Additive mode: only add new folders
362
- const structureDiff = getNewFolders(config.templates[templateName].folders, resolvedPath, ignorePatterns);
223
+ const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
224
+ ? fileTemplateConfig.folders
225
+ : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
226
+
227
+ const existingFolders = config.templates[templateName].folders || [];
228
+ const newFolders = detectedFolders.filter(f => !existingFolders.some(ef => ef.name === f.name));
363
229
 
364
- if (structureDiff.added.length > 0) {
230
+ if (newFolders.length > 0) {
365
231
  console.log(chalk.cyan(`\n📊 New Folders:`));
366
- console.log(chalk.green(` + ${structureDiff.added.length} new folder(s): ${structureDiff.added.map(f => f.name).join(', ')}`));
232
+ console.log(chalk.green(` + ${newFolders.length} new folder(s): ${newFolders.map(f => f.name).join(', ')}`));
367
233
 
368
- // Show interactive checkbox for new folders
369
- if (!options.yes && !options.json) {
370
- const { selectedFolders } = await inquirer.prompt({
371
- type: 'checkbox',
372
- name: 'selectedFolders',
373
- message: 'Select folders to include in template structure (space to toggle):',
374
- loop: false,
375
- theme: {
376
- icon: {
377
- checked: chalk.green('[x] '),
378
- unchecked: '[ ] ',
379
- }
380
- },
381
- choices: structureDiff.added.map(f => ({
382
- name: f.name,
383
- checked: true // Auto-select by default
384
- }))
385
- });
386
- // Only add selected folders
387
- folders = [...config.templates[templateName].folders, ...structureDiff.added.filter(f => selectedFolders.includes(f.name))];
388
- } else {
389
- // In --yes or --json mode, auto-add all
390
- folders = [...config.templates[templateName].folders, ...structureDiff.added];
391
- }
234
+ const addedFolders = await promptNewFolders(newFolders, options);
235
+ folders = [...existingFolders, ...addedFolders];
392
236
  } else {
393
- console.log(chalk.cyan("No new folders detected"));
394
- folders = config.templates[templateName].folders;
237
+ printNoNewFolders();
238
+ folders = existingFolders;
395
239
  }
396
240
  } else {
397
241
  // Full mode: original behavior
@@ -400,183 +244,83 @@ export async function update(sourcePath: string, templateName: string, options:
400
244
  : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
401
245
  }
402
246
 
403
- // 2. Content Selection (Root only) - Additive mode
404
- const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
405
- .filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
406
- .filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
247
+ // 2. Content Selection (Root only)
248
+ const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
249
+ .filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
250
+ .filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
407
251
 
408
- const rootFiles = rootEntries.filter(e => e.isFile()).map(e => e.name);
409
- const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
252
+ const rootFiles = rootEntries.filter(e => e.isFile()).map(e => e.name);
253
+ const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
410
254
 
411
- let selectedFiles: string[] = [];
412
- let selectedFolders: string[] = [];
413
- let selectedStructure: string[] = [];
414
-
415
- if (!isFullMode) {
416
- // Additive mode for files and folders
417
- // Start with existing files and folders, preserving their substitute_variables settings
418
- const existingCopyFiles = config.templates[templateName].copy_files || [];
419
- selectedFiles = existingCopyFiles.map(f => f.src);
420
-
421
- const fileDiff = getNewFiles(config.templates[templateName], resolvedPath);
422
-
423
- if (fileDiff.newFiles.length > 0) {
424
- console.log(chalk.cyan(`\n📊 New Files:`));
425
- console.log(chalk.green(` + ${fileDiff.newFiles.length} new file(s): ${fileDiff.newFiles.join(', ')}`));
426
-
427
- // Show interactive checkbox for new files
428
- if (!options.yes && !options.json) {
429
- const { selectedFileChoices } = await inquirer.prompt({
430
- type: 'checkbox',
431
- name: 'selectedFileChoices',
432
- message: 'Select files to include (space to toggle):',
433
- loop: false,
434
- theme: {
435
- icon: {
436
- checked: chalk.green('[x] '),
437
- unchecked: '[ ] ',
438
- }
439
- },
440
- choices: fileDiff.newFiles.map(f => ({
441
- name: f,
442
- checked: true // Auto-select by default
443
- }))
444
- });
445
- // Only add selected files
446
- selectedFiles.push(...fileDiff.newFiles.filter(f => selectedFileChoices.includes(f)));
447
- } else {
448
- // In --yes or --json mode, auto-add all
449
- selectedFiles.push(...fileDiff.newFiles);
450
- }
451
- } else {
452
- console.log(chalk.cyan("No new files detected"));
453
- }
255
+ let selectedFiles: string[] = [];
256
+ let selectedFolders: string[] = [];
257
+ let selectedStructure: string[] = [];
258
+
259
+ if (!isFullMode) {
260
+ // Additive mode for files and folders
261
+ const existingCopyFiles = config.templates[templateName].copy_files || [];
454
262
 
455
- // For folders, use additive mode logic
456
- // Always preserve existing folders first
263
+ // New files
264
+ const newFiles = rootFiles.filter(f => !existingCopyFiles.some(cf => cf.src === f));
265
+ printNewFiles(newFiles.length, newFiles);
266
+ const addedFiles = await promptNewFiles(newFiles, options);
267
+ selectedFiles = [...existingCopyFiles.filter(cf => !rootDirs.includes(cf.src)).map(cf => cf.src), ...addedFiles];
268
+
269
+ // Structure
457
270
  selectedStructure = config.templates[templateName].folders?.map(f => f.name) || [];
458
- selectedFolders = selectedStructure.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
459
-
271
+ // Seed selectedFolders from existing copy_files directory entries
272
+ selectedFolders = existingCopyFiles
273
+ .filter(f => rootDirs.includes(f.src))
274
+ .map(f => f.src);
275
+
460
276
  if (rootDirs.length > 0) {
461
- const structureDiff = getNewFolders(config.templates[templateName].folders, resolvedPath, ignorePatterns);
277
+ const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
278
+ ? fileTemplateConfig.folders
279
+ : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
280
+ const newFolders = detectedFolders.filter(f => !config.templates[templateName].folders?.some(ef => ef.name === f.name));
462
281
 
463
- if (structureDiff.added.length > 0) {
464
- // Auto-select added folders for structure
465
- selectedStructure = [...new Set([...selectedStructure, ...structureDiff.added.map(f => f.name)])];
466
- // Auto-select for recursive copy if they're in the standard list
467
- selectedFolders = selectedStructure.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
468
- } else {
469
- // Keep existing folders
470
- selectedFolders = selectedStructure.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
471
- }
472
- } else {
473
- // Keep existing folders
474
- selectedFolders = selectedStructure.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
282
+ const addedDirs = newFolders
283
+ .filter(f => ['APP', 'scripts', 'bin'].some(p => f.name === p))
284
+ .map(f => f.name);
285
+ selectedStructure = [...new Set([...selectedStructure, ...addedDirs])];
286
+ selectedFolders = [...new Set([...selectedFolders, ...addedDirs])];
475
287
  }
476
288
  } else {
477
289
  // Full mode: original behavior
478
290
  if (options.yes || options.json) {
479
- // If --yes, auto-select the defaults
480
291
  selectedFiles = rootFiles.filter(f => ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase()));
481
- selectedStructure = rootDirs; // Include all folders in structure
482
- selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p)); // Only copy specific ones recursively
292
+ selectedStructure = rootDirs;
293
+ selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
483
294
  } else {
484
- if (rootFiles.length > 0) {
485
- const filesResponse = await inquirer.prompt({
486
- type: 'checkbox',
487
- name: 'selectedFiles',
488
- message: 'Select root files to include as boilerplate:',
489
- loop: false,
490
- theme: {
491
- icon: {
492
- checked: chalk.green('[x] '),
493
- unchecked: '[ ] ',
494
- }
495
- },
496
- choices: rootFiles.map(f => ({
497
- name: f,
498
- checked: ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase())
499
- }))
500
- });
501
- selectedFiles = filesResponse.selectedFiles;
502
- } else {
503
- selectedFiles = [];
504
- }
505
-
506
- if (rootDirs.length > 0) {
507
- const foldersResponse = await inquirer.prompt({
508
- type: 'checkbox',
509
- name: 'selectedStructure',
510
- message: 'Select folders to include in the template structure (skeleton):',
511
- loop: false,
512
- theme: {
513
- icon: {
514
- checked: chalk.green('[x] '),
515
- unchecked: '[ ] ',
516
- }
517
- },
518
- choices: rootDirs.map(d => ({
519
- name: d,
520
- checked: true // Include all in structure by default
521
- }))
522
- });
523
- selectedStructure = foldersResponse.selectedStructure;
524
- } else {
525
- selectedStructure = [];
526
- }
527
-
528
- if (selectedStructure.length > 0) {
529
- const copyFoldersResponse = await inquirer.prompt({
530
- type: 'checkbox',
531
- name: 'selectedFolders',
532
- message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
533
- loop: false,
534
- theme: {
535
- icon: {
536
- checked: chalk.green('[x] '),
537
- unchecked: '[ ] ',
538
- }
539
- },
540
- choices: selectedStructure.map((d: string) => ({
541
- name: d,
542
- checked: ['APP', 'scripts', 'bin'].some(p => d === p)
543
- }))
544
- });
545
- selectedFolders = copyFoldersResponse.selectedFolders;
546
- } else {
547
- selectedFolders = [];
548
- }
295
+ selectedFiles = await promptRootFiles(rootFiles, undefined, options);
296
+ selectedStructure = await promptStructureFolders(rootDirs, options);
297
+ selectedFolders = await promptCopyFolders(selectedStructure, undefined, options);
549
298
  }
550
299
  }
551
300
 
552
301
  const copy_files: CopyFileEntry[] = [];
553
302
  if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
554
303
  copy_files.push(...fileTemplateConfig.copy_files);
555
- } else {
304
+ } else if (!isFullMode) {
556
305
  const existingCopyFiles = config.templates[templateName].copy_files || [];
557
- const existingMap = new Map<string, CopyFileEntry>();
558
- for (const entry of existingCopyFiles) {
559
- existingMap.set(entry.src, entry);
560
- }
561
- const addedSrcs = new Set<string>();
306
+ const existingSrcs = new Set(existingCopyFiles.map(e => e.src));
307
+
308
+ copy_files.push(...existingCopyFiles);
562
309
 
563
310
  for (const f of selectedFiles) {
564
- if (addedSrcs.has(f)) continue;
565
- addedSrcs.add(f);
566
- if (existingMap.has(f)) {
567
- copy_files.push(existingMap.get(f)!);
568
- } else {
569
- copy_files.push({ src: f, dest: f, substitute_variables: true });
570
- }
311
+ if (existingSrcs.has(f)) continue;
312
+ copy_files.push({ src: f, dest: f, substitute_variables: true });
571
313
  }
572
314
  for (const d of selectedFolders) {
573
- if (addedSrcs.has(d)) continue;
574
- addedSrcs.add(d);
575
- if (existingMap.has(d)) {
576
- copy_files.push(existingMap.get(d)!);
577
- } else {
578
- copy_files.push({ src: d, dest: d, substitute_variables: true });
579
- }
315
+ if (existingSrcs.has(d)) continue;
316
+ copy_files.push({ src: d, dest: d, substitute_variables: true });
317
+ }
318
+ } else {
319
+ for (const f of selectedFiles) {
320
+ copy_files.push({ src: f, dest: f, substitute_variables: true });
321
+ }
322
+ for (const d of selectedFolders) {
323
+ copy_files.push({ src: d, dest: d, substitute_variables: true });
580
324
  }
581
325
  }
582
326
 
@@ -589,36 +333,13 @@ export async function update(sourcePath: string, templateName: string, options:
589
333
  };
590
334
 
591
335
  // Check for post_config scripts
592
- let postConfigTasks: PostConfigTask[] = [];
593
- if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
594
- postConfigTasks = [...fileTemplateConfig.post_config];
595
- } else {
596
- const shPath = path.join(resolvedPath, 'post_config.sh');
597
- const batPath = path.join(resolvedPath, 'post_config.bat');
598
- if (fs.existsSync(shPath)) {
599
- const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
600
- let currentDesc = '';
601
- for (const line of lines) {
602
- if (line.startsWith('echo "Running: ')) {
603
- currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
604
- } else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
605
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
606
- currentDesc = '';
607
- }
608
- }
609
- } else if (fs.existsSync(batPath)) {
610
- const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
611
- let currentDesc = '';
612
- for (const line of lines) {
613
- if (line.startsWith('echo Running: ')) {
614
- currentDesc = line.substring(14).trim();
615
- } else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
616
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
617
- currentDesc = '';
618
- }
619
- }
620
- }
621
- }
336
+ const shPath = path.join(resolvedPath, 'post_config.sh');
337
+ const batPath = path.join(resolvedPath, 'post_config.bat');
338
+ const detectedTasks = parsePostConfigScript(shPath, batPath);
339
+
340
+ let postConfigTasks: PostConfigTask[] = config.templates[templateName].post_config || [];
341
+ postConfigTasks = mergePostConfigTasks(postConfigTasks, fileTemplateConfig.post_config, detectedTasks);
342
+
622
343
  if (postConfigTasks.length > 0) {
623
344
  templateConfig.post_config = postConfigTasks;
624
345
  if (!options.json && !fileTemplateConfig.post_config) console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
@@ -629,35 +350,7 @@ export async function update(sourcePath: string, templateName: string, options:
629
350
  const defaultApplicableTasks = defaultPostConfig.filter(t => !t.type || t.type === templateName);
630
351
 
631
352
  if (defaultApplicableTasks.length > 0) {
632
- let selectedTaskNames: string[] = [];
633
- if (options.yes || options.json) {
634
- selectedTaskNames = defaultApplicableTasks.map(t => t.command || t.script || '');
635
- } else {
636
- const choices: Array<{name: string; value: string; checked?: boolean}> = [];
637
- for (const t of defaultApplicableTasks) {
638
- const cmd = t.command || t.script || '(no command)';
639
- const desc = t.description ? ` (${t.description})` : '';
640
- choices.push({
641
- name: `${cmd}${desc}`,
642
- value: cmd,
643
- checked: t.checked !== false
644
- });
645
- }
646
- const response = await inquirer.prompt({
647
- type: 'checkbox',
648
- name: 'selected',
649
- message: 'Select default post-config tasks to include in this template:',
650
- loop: false,
651
- theme: {
652
- icon: {
653
- checked: chalk.green('[x] '),
654
- unchecked: '[ ] ',
655
- }
656
- },
657
- choices
658
- });
659
- selectedTaskNames = response.selected || [];
660
- }
353
+ const selectedTaskNames = await promptPostConfigTasks(defaultApplicableTasks, options);
661
354
 
662
355
  if (selectedTaskNames.length > 0) {
663
356
  if (!templateConfig.post_config) templateConfig.post_config = [];
@@ -674,50 +367,12 @@ export async function update(sourcePath: string, templateName: string, options:
674
367
  }
675
368
 
676
369
  // 3. Detect executables at root
677
- const detectedExecutables: string[] = [];
678
- for (const file of rootFiles) {
679
- const fullPath = path.join(resolvedPath, file);
680
- if (isExecutable(fullPath, file)) {
681
- detectedExecutables.push(file);
682
- }
683
- }
370
+ const detectedExecutables = rootFiles
371
+ .filter(file => isExecutable(path.join(resolvedPath, file), file))
372
+ .filter(file => !shouldExcludeFile(file));
684
373
 
685
374
  const existingPostCopy = config.templates[templateName].post_copy || [];
686
- let post_copy = [...existingPostCopy];
687
-
688
- if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
689
- for (const pc of fileTemplateConfig.post_copy) {
690
- if (!post_copy.some(existing => existing.src === pc.src)) {
691
- post_copy.push(pc);
692
- }
693
- }
694
- }
695
-
696
- const newExecutables = detectedExecutables.filter(file => !post_copy.some(existing => existing.src === file));
697
-
698
- if (newExecutables.length > 0) {
699
- if (!options.json) {
700
- console.log(chalk.cyan("\nAuto-detected " + newExecutables.length + " new executable file(s) at root:"));
701
- for (const file of newExecutables) {
702
- console.log(chalk.gray(" - " + file));
703
- }
704
- }
705
- let addPostCopy = true;
706
- if (!options.yes && !options.json) {
707
- const response = await inquirer.prompt({
708
- type: 'confirm',
709
- name: 'addPostCopy',
710
- message: 'Add these to post_copy (auto-chmod)?',
711
- default: true
712
- });
713
- addPostCopy = response.addPostCopy;
714
- }
715
- if (addPostCopy) {
716
- for (const file of newExecutables) {
717
- post_copy.push({ src: file, dest: file });
718
- }
719
- }
720
- }
375
+ const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy, detectedExecutables);
721
376
 
722
377
  if (post_copy.length > 0) {
723
378
  templateConfig.post_copy = post_copy;
@@ -726,13 +381,11 @@ export async function update(sourcePath: string, templateName: string, options:
726
381
  }
727
382
 
728
383
  if (options.json) {
729
- const output = {
730
- name: templateName,
731
- ...templateConfig
732
- };
733
-
734
- // Force the application to wait until every single byte of this JSON string
735
- // safely clears the operating system's pipe buffer before letting the process die.
384
+ const output = {
385
+ name: templateName,
386
+ ...templateConfig
387
+ };
388
+
736
389
  process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
737
390
  process.exit(0);
738
391
  });
@@ -743,93 +396,4 @@ export async function update(sourcePath: string, templateName: string, options:
743
396
  saveConfig(config);
744
397
 
745
398
  console.log(chalk.green(`\n✓ Template saved as "${templateName}"`));
746
- }
747
-
748
- function isExecutable(fullPath: string, fileName: string): boolean {
749
- if (shouldExcludeFile(fileName)) return false;
750
- const ext = path.extname(fileName).toLowerCase();
751
- if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext)) return true;
752
- if (fileName.toLowerCase() === 'makefile') return true;
753
- try {
754
- const stat = fs.statSync(fullPath);
755
- return !!(stat.mode & 0o111);
756
- } catch {
757
- return false;
758
- }
759
- }
760
-
761
- function extractStructure(dirPath: string, rootPath: string, ignorePatterns?: string[]): FolderNode[] {
762
- let nodes: FolderNode[] = [];
763
- try {
764
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
765
- for (const entry of entries) {
766
- const fullPath = path.join(dirPath, entry.name);
767
- const relativePath = path.relative(rootPath, fullPath);
768
-
769
- // Only include directories in the structure skeleton
770
- const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
771
- if (!isDirectory) continue;
772
-
773
- if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
774
- if (shouldExclude(dirPath, fullPath)) continue;
775
-
776
- const children = extractStructure(fullPath, rootPath, ignorePatterns);
777
- let info = "";
778
- const gitkeepPath = path.join(fullPath, '.gitkeep.md');
779
- const infoPath = path.join(fullPath, '.info.md');
780
- if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
781
- else if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
782
- nodes.push({ name: entry.name, info: info, children: children });
783
- }
784
- } catch (e) {}
785
- return nodes;
786
- }
787
-
788
- /**
789
- * Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
790
- */
791
- function findVariablesInFiles(dirPath: string, rootPath: string, ignorePatterns?: string[]): string[] {
792
- const variables = new Set<string>();
793
- const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
794
-
795
- const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
796
-
797
- const scan = (currentPath: string, depth: number) => {
798
- if (depth > 1) return; // Top level (0) and 1st level subfolders (1)
799
-
800
- try {
801
- const entries = fs.readdirSync(currentPath, { withFileTypes: true });
802
- for (const entry of entries) {
803
- const fullPath = path.join(currentPath, entry.name);
804
- const relativePath = path.relative(rootPath, fullPath);
805
-
806
- if (entry.isDirectory()) {
807
- if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
808
- if (shouldExclude(currentPath, fullPath)) continue;
809
- scan(fullPath, depth + 1);
810
- } else if (entry.isFile()) {
811
- const ext = path.extname(entry.name).toLowerCase();
812
- const isMakefile = entry.name.toLowerCase() === 'makefile';
813
-
814
- if (textExtensions.includes(ext) || isMakefile || ext === '') {
815
- try {
816
- const content = fs.readFileSync(fullPath, 'utf-8');
817
- let match;
818
- regex.lastIndex = 0;
819
- while ((match = regex.exec(content)) !== null) {
820
- variables.add(match[1]);
821
- }
822
- } catch (e) {
823
- // Skip files that can't be read or aren't text
824
- }
825
- }
826
- }
827
- }
828
- } catch (e) {
829
- // Ignore directory read errors
830
- }
831
- };
832
-
833
- scan(dirPath, 0);
834
- return Array.from(variables);
835
399
  }