@garyr/pt-cli 0.40.1 → 0.42.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,10 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import inquirer from 'inquirer';
4
- import { loadConfig, saveConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, getDefaultPostConfig } from '../config.js';
4
+ import { loadConfig, saveConfig, getTemplateNames, shouldExcludeFile, getDefaultPostConfig } from '../config.js';
5
5
  import chalk from 'chalk';
6
6
  import { downloadAndExtract } from '../remote.js';
7
+ import { extractStructure, findVariablesInFiles, isExecutable, parseInfoFile, loadJsonTemplateConfig, getRootEntries, parsePostConfigScript, mergePostConfigTasks, mergePostCopyFiles, buildCopyFiles, promptTemplateName, promptDescription, promptAdditionalVariables, promptNewVariables, promptGlobalVariables, printNoNewVariables, promptNewFolders, printNoNewFolders, printNewFiles, promptNewFiles, promptRootFiles, promptStructureFolders, promptCopyFolders, promptPostConfigTasks } from './template-utils.js';
7
8
  export async function learn(sourcePath, updateTemplate = null, options = {}) {
8
9
  let resolvedPath;
9
10
  // Phase 1: Remote Check
@@ -30,51 +31,34 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
30
31
  const isUpdate = !!updateTemplate;
31
32
  const config = loadConfig();
32
33
  const existingNames = getTemplateNames(config);
33
- // Check for template configuration JSON file (.pt-template.json or template.json)
34
- let fileTemplateConfig = {};
35
- const jsonConfigPaths = [
36
- path.join(resolvedPath, '.pt-template.json'),
37
- path.join(resolvedPath, 'template.json')
38
- ];
39
- for (const jPath of jsonConfigPaths) {
40
- if (fs.existsSync(jPath)) {
41
- try {
42
- const fileContent = fs.readFileSync(jPath, 'utf-8');
43
- fileTemplateConfig = JSON.parse(fileContent);
44
- if (!options.json)
45
- console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
34
+ // Check for template configuration JSON file
35
+ const fileTemplateConfig = loadJsonTemplateConfig(resolvedPath);
36
+ if (fileTemplateConfig && !options.json) {
37
+ // Find which file was loaded
38
+ const jsonConfigPaths = [
39
+ path.join(resolvedPath, '.pt-template.json'),
40
+ path.join(resolvedPath, 'template.json')
41
+ ];
42
+ for (const jPath of jsonConfigPaths) {
43
+ if (fs.existsSync(jPath)) {
44
+ console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
46
45
  break;
47
46
  }
48
- catch (e) {
49
- console.warn(chalk.yellow(`Warning: Failed to parse ${path.basename(jPath)}: ${e.message}`));
50
- }
51
47
  }
52
48
  }
53
49
  // Check for .info.md
54
- let infoName = '';
55
- let infoDesc = '';
56
- const infoPath = path.join(resolvedPath, '.info.md');
57
- if (fs.existsSync(infoPath)) {
58
- const infoContent = fs.readFileSync(infoPath, 'utf-8');
59
- const lines = infoContent.split('\n');
60
- for (const line of lines) {
61
- if (line.startsWith('# ')) {
62
- infoName = line.substring(2).trim();
63
- }
64
- else if (line.trim() !== '' && !infoDesc && !line.startsWith('#')) {
65
- infoDesc = line.trim();
66
- }
67
- }
68
- }
50
+ const { name: infoName, description: infoDesc } = parseInfoFile(path.join(resolvedPath, '.info.md'));
51
+ // --- NAME ---
69
52
  let targetName = updateTemplate || '';
70
53
  if (isUpdate) {
54
+ // When updating, use the provided template name directly
55
+ // No auto-detection from files needed for updates
71
56
  if (!targetName || !config.templates[targetName]) {
72
57
  console.error(chalk.red(`Template "${targetName}" not found.`));
73
58
  process.exit(1);
74
59
  }
75
60
  }
76
61
  else {
77
- // Track whether name came from .info.md or JSON (not user-provided)
78
62
  const nameFromSource = !options.name && (fileTemplateConfig.name || infoName);
79
63
  if (options.name) {
80
64
  targetName = options.name;
@@ -89,50 +73,24 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
89
73
  if (!options.json)
90
74
  console.log(chalk.cyan(`Auto-detected template name from .info.md: ${targetName}`));
91
75
  }
92
- else {
93
- if (options.yes || options.json) {
94
- targetName = path.basename(resolvedPath);
95
- }
96
- else {
97
- const { newName } = await inquirer.prompt({
98
- type: 'input',
99
- name: 'newName',
100
- message: 'Name this template:',
101
- default: path.basename(resolvedPath)
102
- });
103
- targetName = newName;
104
- }
76
+ else if (options.yes || options.json) {
77
+ targetName = path.basename(resolvedPath);
105
78
  }
106
- // If name came from .info.md or JSON, prompt to confirm/edit
107
- // This prevents accidental overwrites when creating new templates based on existing ones
108
- if (nameFromSource && !options.json) {
109
- if (options.yes) {
110
- // In --yes mode, keep the auto-detected name but warn
111
- const existingNames = getTemplateNames(config);
112
- if (existingNames.includes(targetName)) {
113
- console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
114
- }
115
- }
116
- else {
117
- const { confirmName } = await inquirer.prompt({
118
- type: 'confirm',
119
- name: 'confirmName',
120
- message: `Use "${targetName}" as the template name?`,
121
- default: true
122
- });
123
- if (!confirmName) {
124
- const { newName } = await inquirer.prompt({
125
- type: 'input',
126
- name: 'newName',
127
- message: 'Name this template:',
128
- default: targetName
129
- });
130
- targetName = newName;
131
- }
132
- }
79
+ else {
80
+ const { newName } = await inquirer.prompt({
81
+ type: 'input',
82
+ name: 'newName',
83
+ message: 'Name this template:',
84
+ default: path.basename(resolvedPath)
85
+ });
86
+ targetName = newName;
133
87
  }
88
+ // Confirm name if auto-detected
89
+ targetName = await promptTemplateName(targetName, existingNames, nameFromSource ? targetName : null, options);
134
90
  }
91
+ // --- DESCRIPTION ---
135
92
  let description = '';
93
+ const descFromSource = !options.desc && (fileTemplateConfig.description || infoDesc);
136
94
  if (options.desc) {
137
95
  description = options.desc;
138
96
  }
@@ -152,19 +110,11 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
152
110
  description = currentDesc;
153
111
  }
154
112
  else {
155
- const { newDesc } = await inquirer.prompt({
156
- type: 'input',
157
- name: 'newDesc',
158
- message: 'Purpose/Description of this template:',
159
- default: currentDesc
160
- });
161
- description = newDesc;
113
+ description = await promptDescription(currentDesc, options);
162
114
  }
163
115
  }
164
116
  }
165
117
  else {
166
- // Track whether description came from .info.md or JSON (not user-provided)
167
- const descFromSource = !options.desc && (fileTemplateConfig.description || infoDesc);
168
118
  if (fileTemplateConfig.description) {
169
119
  description = fileTemplateConfig.description;
170
120
  if (!options.json)
@@ -175,29 +125,12 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
175
125
  if (!options.json)
176
126
  console.log(chalk.cyan(`Auto-detected template description from .info.md: ${description}`));
177
127
  }
178
- else if (options.yes || options.json) {
179
- description = infoDesc || '';
180
- }
181
128
  else {
182
- const { newDesc } = await inquirer.prompt({
183
- type: 'input',
184
- name: 'newDesc',
185
- message: 'Purpose/Description of this template:',
186
- default: targetName
187
- });
188
- description = newDesc;
129
+ description = await promptDescription(infoDesc || targetName, options);
189
130
  }
190
- // If description came from .info.md or JSON, prompt to confirm/edit
191
- // This prevents accidental overwrites when creating new templates based on existing ones
131
+ // Confirm description if auto-detected
192
132
  if (descFromSource && !options.json) {
193
- if (options.yes) {
194
- // In --yes mode, keep the auto-detected description but warn
195
- const existingNames = getTemplateNames(config);
196
- if (existingNames.includes(targetName)) {
197
- console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
198
- }
199
- }
200
- else {
133
+ if (!options.yes) {
201
134
  const { confirmDesc } = await inquirer.prompt({
202
135
  type: 'confirm',
203
136
  name: 'confirmDesc',
@@ -205,26 +138,20 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
205
138
  default: true
206
139
  });
207
140
  if (!confirmDesc) {
208
- const { newDesc } = await inquirer.prompt({
209
- type: 'input',
210
- name: 'newDesc',
211
- message: 'Purpose/Description of this template:',
212
- default: description
213
- });
214
- description = newDesc;
141
+ description = await promptDescription(description, options);
215
142
  }
216
143
  }
217
144
  }
218
145
  }
146
+ // --- IGNORE PATTERNS ---
219
147
  const cliIgnore = options.ignore ? options.ignore.split(',').map((s) => s.trim()).filter(Boolean) : [];
220
148
  const ignorePatterns = [...(config.ignore || []), ...cliIgnore];
221
- // Detect variables from files
149
+ // --- VARIABLES ---
222
150
  const detectedVars = findVariablesInFiles(resolvedPath, resolvedPath, ignorePatterns);
223
151
  if (detectedVars.length > 0 && !options.json) {
224
152
  console.log(chalk.cyan(`Auto-detected ${detectedVars.length} variable(s): ${detectedVars.join(', ')}`));
225
153
  }
226
154
  let variables = [];
227
- // During updates, merge existing template variables with JSON file variables
228
155
  if (isUpdate) {
229
156
  // Start with existing template variables
230
157
  if (config.templates[updateTemplate].variables) {
@@ -235,11 +162,9 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
235
162
  for (const v of fileTemplateConfig.variables) {
236
163
  const existingIndex = variables.findIndex(existing => existing.name === v.name);
237
164
  if (existingIndex !== -1) {
238
- // Update existing variable with JSON values (but preserve other fields)
239
165
  variables[existingIndex] = { ...variables[existingIndex], ...v };
240
166
  }
241
167
  else {
242
- // Add new variable
243
168
  variables.push({ ...v });
244
169
  }
245
170
  }
@@ -254,11 +179,7 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
254
179
  // Add detected variables if not already present
255
180
  for (const varName of detectedVars) {
256
181
  if (!variables.some(v => v.name === varName)) {
257
- variables.push({
258
- name: varName,
259
- prompt: `Enter ${varName}:`,
260
- required: true
261
- });
182
+ variables.push({ name: varName, prompt: `Enter ${varName}:`, required: true });
262
183
  }
263
184
  }
264
185
  // Include global variables as suggestions
@@ -269,137 +190,83 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
269
190
  }
270
191
  }
271
192
  }
272
- let hasMoreVariables = false;
273
- if (!options.yes && !options.json) {
274
- const message = variables.length > 0
275
- ? `Detected/Existing variables: ${variables.map(v => v.name).join(', ')}. Define more?`
276
- : 'Define template variables (e.g., client_name, project_type)?';
277
- const response = await inquirer.prompt({
278
- type: 'confirm',
279
- name: 'hasMoreVariables',
280
- message: message,
281
- default: false
282
- });
283
- hasMoreVariables = response.hasMoreVariables;
193
+ // Variable prompting
194
+ if (!isUpdate || options.noDiff === undefined) {
195
+ // New template or full mode - prompt for additional variables
196
+ const additionalVars = await promptAdditionalVariables(variables, options);
197
+ variables.push(...additionalVars);
284
198
  }
285
- if (hasMoreVariables) {
286
- const { variableDefs } = await inquirer.prompt({
287
- type: 'input',
288
- name: 'variableDefs',
289
- message: 'Define additional variables as comma-separated names:',
290
- });
291
- if (variableDefs) {
292
- const additionalVars = variableDefs.split(',').map((v) => v.trim()).filter(Boolean);
293
- for (const v of additionalVars) {
294
- if (!variables.some(existing => existing.name === v)) {
295
- variables.push({
296
- name: v,
297
- prompt: `Enter ${v}:`,
298
- required: true
299
- });
300
- }
301
- }
199
+ else {
200
+ // Additive mode - only prompt for new variables
201
+ const existingVarNames = new Set(config.templates[updateTemplate].variables?.map(v => v.name) || []);
202
+ const newVars = variables.filter(v => !existingVarNames.has(v.name));
203
+ if (newVars.length > 0) {
204
+ const selectedNewVars = await promptNewVariables(newVars, options);
205
+ variables.push(...selectedNewVars);
206
+ }
207
+ else {
208
+ printNoNewVariables();
209
+ }
210
+ // Also prompt for global variables not already included
211
+ const globalVarsToPrompt = (config.variables || []).filter(gv => !variables.some(v => v.name === gv.name));
212
+ if (globalVarsToPrompt.length > 0) {
213
+ const selectedGlobals = await promptGlobalVariables(globalVarsToPrompt, options);
214
+ variables.push(...selectedGlobals);
302
215
  }
303
216
  }
304
- // 1. Structure (skeleton)
305
- const folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
217
+ // --- STRUCTURE ---
218
+ const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
306
219
  ? fileTemplateConfig.folders
307
220
  : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
308
- // 2. Content Selection (Root only)
309
- const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
310
- .filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
311
- .filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
312
- const rootFiles = rootEntries.filter(e => e.isFile()).map(e => e.name);
313
- const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
221
+ // --- CONTENT SELECTION (Root only) ---
222
+ const { files: rootFiles, dirs: rootDirs } = getRootEntries(resolvedPath, ignorePatterns);
314
223
  let selectedFiles = [];
315
224
  let selectedFolders = [];
316
225
  let selectedStructure = [];
317
- if (options.yes || options.json) {
318
- // If --yes, auto-select the defaults
319
- selectedFiles = rootFiles.filter(f => ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase()));
320
- selectedStructure = rootDirs; // Include all folders in structure
321
- selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p)); // Only copy specific ones recursively
322
- }
323
- else {
324
- if (rootFiles.length > 0) {
325
- const filesResponse = await inquirer.prompt({
326
- type: 'checkbox',
327
- name: 'selectedFiles',
328
- message: 'Select root files to include as boilerplate:',
329
- loop: false,
330
- theme: {
331
- icon: {
332
- checked: chalk.green('[x] '),
333
- unchecked: '[ ] ',
334
- }
335
- },
336
- choices: rootFiles.map(f => ({
337
- name: f,
338
- checked: ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase())
339
- }))
340
- });
341
- selectedFiles = filesResponse.selectedFiles;
226
+ let folders;
227
+ if (isUpdate) {
228
+ // Additive mode for updates
229
+ const existingTemplate = config.templates[updateTemplate];
230
+ // New folders
231
+ const newFolders = detectedFolders.filter((f) => !existingTemplate.folders?.some((ef) => ef.name === f.name));
232
+ if (newFolders.length > 0) {
233
+ const addedFolders = await promptNewFolders(newFolders, options);
234
+ // Create new folders array preserving existing
235
+ folders = [...existingTemplate.folders, ...addedFolders];
342
236
  }
343
237
  else {
344
- selectedFiles = [];
345
- }
238
+ printNoNewFolders();
239
+ folders = existingTemplate.folders;
240
+ }
241
+ // New files
242
+ const newFiles = rootFiles.filter((f) => !existingTemplate.copy_files?.some((cf) => cf.src === f));
243
+ printNewFiles(newFiles.length, newFiles);
244
+ const addedFiles = await promptNewFiles(newFiles, options);
245
+ selectedFiles = [...(existingTemplate.copy_files?.filter((cf) => !rootDirs.includes(cf.src)).map((cf) => cf.src) || []), ...addedFiles];
246
+ // Structure
247
+ selectedStructure = existingTemplate.folders?.map((f) => f.name) || [];
248
+ // Seed selectedFolders from existing copy_files directory entries
249
+ selectedFolders = (existingTemplate.copy_files || [])
250
+ .filter((f) => rootDirs.includes(f.src))
251
+ .map((f) => f.src);
346
252
  if (rootDirs.length > 0) {
347
- const foldersResponse = await inquirer.prompt({
348
- type: 'checkbox',
349
- name: 'selectedStructure',
350
- message: 'Select folders to include in the template structure (skeleton):',
351
- loop: false,
352
- theme: {
353
- icon: {
354
- checked: chalk.green('[x] '),
355
- unchecked: '[ ] ',
356
- }
357
- },
358
- choices: rootDirs.map(d => ({
359
- name: d,
360
- checked: true // Include all in structure by default
361
- }))
362
- });
363
- selectedStructure = foldersResponse.selectedStructure;
364
- }
365
- else {
366
- selectedStructure = [];
253
+ const addedDirs = newFolders
254
+ .filter((f) => ['APP', 'scripts', 'bin'].some(p => f.name === p))
255
+ .map((f) => f.name);
256
+ selectedStructure = [...new Set([...selectedStructure, ...addedDirs])];
257
+ selectedFolders = [...new Set([...selectedFolders, ...addedDirs])];
367
258
  }
368
- if (selectedStructure.length > 0) {
369
- const copyFoldersResponse = await inquirer.prompt({
370
- type: 'checkbox',
371
- name: 'selectedFolders',
372
- message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
373
- loop: false,
374
- theme: {
375
- icon: {
376
- checked: chalk.green('[x] '),
377
- unchecked: '[ ] ',
378
- }
379
- },
380
- choices: selectedStructure.map((d) => ({
381
- name: d,
382
- checked: ['APP', 'scripts', 'bin'].some(p => d === p)
383
- }))
384
- });
385
- selectedFolders = copyFoldersResponse.selectedFolders;
386
- }
387
- else {
388
- selectedFolders = [];
389
- }
390
- }
391
- const copy_files = [];
392
- if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
393
- copy_files.push(...fileTemplateConfig.copy_files);
394
259
  }
395
260
  else {
396
- for (const f of selectedFiles) {
397
- copy_files.push({ src: f, dest: f, substitute_variables: true });
398
- }
399
- for (const d of selectedFolders) {
400
- copy_files.push({ src: d, dest: d, substitute_variables: true });
401
- }
402
- }
261
+ // New template - original behavior
262
+ selectedFiles = await promptRootFiles(rootFiles, undefined, options);
263
+ selectedStructure = await promptStructureFolders(rootDirs, options);
264
+ selectedFolders = await promptCopyFolders(selectedStructure, undefined, options);
265
+ folders = detectedFolders;
266
+ }
267
+ // --- COPY FILES ---
268
+ const existingCopyFiles = isUpdate ? config.templates[updateTemplate].copy_files || [] : [];
269
+ const copy_files = buildCopyFiles(selectedFiles, selectedFolders, existingCopyFiles);
403
270
  const templateConfig = {
404
271
  description: description,
405
272
  templateRoot: resolvedPath,
@@ -407,41 +274,12 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
407
274
  copy_files: copy_files,
408
275
  variables: variables.length > 0 ? variables : undefined
409
276
  };
410
- // Check for post_config scripts
411
- let postConfigTasks = [];
412
- if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
413
- postConfigTasks = [...fileTemplateConfig.post_config];
414
- }
415
- else {
416
- const shPath = path.join(resolvedPath, 'post_config.sh');
417
- const batPath = path.join(resolvedPath, 'post_config.bat');
418
- if (fs.existsSync(shPath)) {
419
- const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
420
- let currentDesc = '';
421
- for (const line of lines) {
422
- if (line.startsWith('echo "Running: ')) {
423
- currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
424
- }
425
- else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
426
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
427
- currentDesc = '';
428
- }
429
- }
430
- }
431
- else if (fs.existsSync(batPath)) {
432
- const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
433
- let currentDesc = '';
434
- for (const line of lines) {
435
- if (line.startsWith('echo Running: ')) {
436
- currentDesc = line.substring(14).trim();
437
- }
438
- else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
439
- postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
440
- currentDesc = '';
441
- }
442
- }
443
- }
444
- }
277
+ // --- POST_CONFIG ---
278
+ const shPath = path.join(resolvedPath, 'post_config.sh');
279
+ const batPath = path.join(resolvedPath, 'post_config.bat');
280
+ const detectedTasks = parsePostConfigScript(shPath, batPath);
281
+ let postConfigTasks = isUpdate ? config.templates[updateTemplate].post_config || [] : [];
282
+ postConfigTasks = mergePostConfigTasks(postConfigTasks, fileTemplateConfig.post_config, detectedTasks);
445
283
  if (postConfigTasks.length > 0) {
446
284
  templateConfig.post_config = postConfigTasks;
447
285
  if (!options.json && !fileTemplateConfig.post_config)
@@ -451,36 +289,7 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
451
289
  const defaultPostConfig = getDefaultPostConfig(config);
452
290
  const defaultApplicableTasks = defaultPostConfig.filter(t => !t.type || t.type === targetName);
453
291
  if (defaultApplicableTasks.length > 0) {
454
- let selectedTaskNames = [];
455
- if (options.yes || options.json) {
456
- selectedTaskNames = defaultApplicableTasks.map(t => t.command || t.script || '');
457
- }
458
- else {
459
- const choices = [];
460
- for (const t of defaultApplicableTasks) {
461
- const cmd = t.command || t.script || '(no command)';
462
- const desc = t.description ? ` (${t.description})` : '';
463
- choices.push({
464
- name: `${cmd}${desc}`,
465
- value: cmd,
466
- checked: t.checked !== false
467
- });
468
- }
469
- const response = await inquirer.prompt({
470
- type: 'checkbox',
471
- name: 'selected',
472
- message: 'Select default post-config tasks to include in this template:',
473
- loop: false,
474
- theme: {
475
- icon: {
476
- checked: chalk.green('[x] '),
477
- unchecked: '[ ] ',
478
- }
479
- },
480
- choices
481
- });
482
- selectedTaskNames = response.selected || [];
483
- }
292
+ const selectedTaskNames = await promptPostConfigTasks(defaultApplicableTasks, options);
484
293
  if (selectedTaskNames.length > 0) {
485
294
  if (!templateConfig.post_config)
486
295
  templateConfig.post_config = [];
@@ -495,48 +304,20 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
495
304
  }
496
305
  }
497
306
  }
498
- // 3. Detect executables at root
499
- const detectedExecutables = [];
500
- for (const file of rootFiles) {
501
- const fullPath = path.join(resolvedPath, file);
502
- if (isExecutable(fullPath, file)) {
503
- detectedExecutables.push(file);
504
- }
505
- }
506
- if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
507
- templateConfig.post_copy = fileTemplateConfig.post_copy;
508
- const postCopySrcs = fileTemplateConfig.post_copy.map(f => f.src);
307
+ // --- POST_COPY (executables) ---
308
+ const detectedExecutables = rootFiles
309
+ .filter(file => isExecutable(path.join(resolvedPath, file), file))
310
+ .filter(file => !shouldExcludeFile(file));
311
+ const existingPostCopy = isUpdate ? config.templates[updateTemplate].post_copy || [] : [];
312
+ const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy, detectedExecutables);
313
+ if (post_copy.length > 0) {
314
+ templateConfig.post_copy = post_copy;
315
+ const postCopySrcs = post_copy.map(f => f.src);
509
316
  templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !postCopySrcs.includes(cf.src));
510
317
  }
511
- else if (detectedExecutables.length > 0) {
512
- if (!options.json) {
513
- console.log(chalk.cyan("\nAuto-detected " + detectedExecutables.length + " executable file(s) at root:"));
514
- for (const file of detectedExecutables) {
515
- console.log(chalk.gray(" - " + file));
516
- }
517
- }
518
- let addPostCopy = true;
519
- if (!options.yes && !options.json) {
520
- const response = await inquirer.prompt({
521
- type: 'confirm',
522
- name: 'addPostCopy',
523
- message: 'Add these to post_copy (auto-chmod)?',
524
- default: true
525
- });
526
- addPostCopy = response.addPostCopy;
527
- }
528
- if (addPostCopy) {
529
- templateConfig.post_copy = detectedExecutables.map(f => ({ src: f, dest: f }));
530
- templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !detectedExecutables.includes(cf.src));
531
- }
532
- }
318
+ // --- OUTPUT ---
533
319
  if (options.json) {
534
- const output = {
535
- name: targetName,
536
- ...templateConfig
537
- };
538
- // Force the application to wait until every single byte of this JSON string
539
- // safely clears the operating system's pipe buffer before letting the process die.
320
+ const output = { name: targetName, ...templateConfig };
540
321
  process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
541
322
  process.exit(0);
542
323
  });
@@ -546,96 +327,3 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
546
327
  saveConfig(config);
547
328
  console.log(chalk.green(`\n✓ Template saved as "${targetName}"`));
548
329
  }
549
- function isExecutable(fullPath, fileName) {
550
- if (shouldExcludeFile(fileName))
551
- return false;
552
- const ext = path.extname(fileName).toLowerCase();
553
- if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext))
554
- return true;
555
- if (fileName.toLowerCase() === 'makefile')
556
- return true;
557
- try {
558
- const stat = fs.statSync(fullPath);
559
- return !!(stat.mode & 0o111);
560
- }
561
- catch {
562
- return false;
563
- }
564
- }
565
- function extractStructure(dirPath, rootPath, ignorePatterns) {
566
- let nodes = [];
567
- try {
568
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
569
- for (const entry of entries) {
570
- const fullPath = path.join(dirPath, entry.name);
571
- const relativePath = path.relative(rootPath, fullPath);
572
- // Only include directories in the structure skeleton
573
- const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
574
- if (!isDirectory)
575
- continue;
576
- if (shouldIgnore(entry.name, relativePath, ignorePatterns))
577
- continue;
578
- if (shouldExclude(dirPath, fullPath))
579
- continue;
580
- const children = extractStructure(fullPath, rootPath, ignorePatterns);
581
- let info = "";
582
- const gitkeepPath = path.join(fullPath, '.gitkeep.md');
583
- const infoPath = path.join(fullPath, '.info.md');
584
- if (fs.existsSync(infoPath))
585
- info = fs.readFileSync(infoPath, 'utf-8').trim();
586
- else if (fs.existsSync(gitkeepPath))
587
- info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
588
- nodes.push({ name: entry.name, info: info, children: children });
589
- }
590
- }
591
- catch (e) { }
592
- return nodes;
593
- }
594
- /**
595
- * Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
596
- */
597
- function findVariablesInFiles(dirPath, rootPath, ignorePatterns) {
598
- const variables = new Set();
599
- const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
600
- const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
601
- const scan = (currentPath, depth) => {
602
- if (depth > 1)
603
- return; // Top level (0) and 1st level subfolders (1)
604
- try {
605
- const entries = fs.readdirSync(currentPath, { withFileTypes: true });
606
- for (const entry of entries) {
607
- const fullPath = path.join(currentPath, entry.name);
608
- const relativePath = path.relative(rootPath, fullPath);
609
- if (entry.isDirectory()) {
610
- if (shouldIgnore(entry.name, relativePath, ignorePatterns))
611
- continue;
612
- if (shouldExclude(currentPath, fullPath))
613
- continue;
614
- scan(fullPath, depth + 1);
615
- }
616
- else if (entry.isFile()) {
617
- const ext = path.extname(entry.name).toLowerCase();
618
- const isMakefile = entry.name.toLowerCase() === 'makefile';
619
- if (textExtensions.includes(ext) || isMakefile || ext === '') {
620
- try {
621
- const content = fs.readFileSync(fullPath, 'utf-8');
622
- let match;
623
- regex.lastIndex = 0;
624
- while ((match = regex.exec(content)) !== null) {
625
- variables.add(match[1]);
626
- }
627
- }
628
- catch (e) {
629
- // Skip files that can't be read or aren't text
630
- }
631
- }
632
- }
633
- }
634
- }
635
- catch (e) {
636
- // Ignore directory read errors
637
- }
638
- };
639
- scan(dirPath, 0);
640
- return Array.from(variables);
641
- }