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