@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.
@@ -0,0 +1,565 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import inquirer from 'inquirer';
4
+ import { shouldIgnore, shouldExclude, shouldExcludeFile } from '../config.js';
5
+ import chalk from 'chalk';
6
+ /**
7
+ * Extract folder structure skeleton from a directory
8
+ * Only includes directories, with optional .info.md content
9
+ */
10
+ export function extractStructure(dirPath, rootPath, ignorePatterns) {
11
+ const nodes = [];
12
+ try {
13
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
14
+ for (const entry of entries) {
15
+ const fullPath = path.join(dirPath, entry.name);
16
+ const relativePath = path.relative(rootPath, fullPath);
17
+ // Only include directories in the structure skeleton
18
+ const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
19
+ if (!isDirectory)
20
+ continue;
21
+ if (shouldIgnore(entry.name, relativePath, ignorePatterns))
22
+ continue;
23
+ if (shouldExclude(dirPath, fullPath))
24
+ continue;
25
+ const children = extractStructure(fullPath, rootPath, ignorePatterns);
26
+ let info = '';
27
+ const gitkeepPath = path.join(fullPath, '.gitkeep.md');
28
+ const infoPath = path.join(fullPath, '.info.md');
29
+ if (fs.existsSync(infoPath))
30
+ info = fs.readFileSync(infoPath, 'utf-8').trim();
31
+ else if (fs.existsSync(gitkeepPath))
32
+ info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
33
+ nodes.push({ name: entry.name, info, children });
34
+ }
35
+ }
36
+ catch (e) {
37
+ // Ignore directory read errors
38
+ }
39
+ return nodes;
40
+ }
41
+ /**
42
+ * Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
43
+ */
44
+ export function findVariablesInFiles(dirPath, rootPath, ignorePatterns) {
45
+ const variables = new Set();
46
+ const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
47
+ const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
48
+ const scan = (currentPath, depth) => {
49
+ if (depth > 1)
50
+ return; // Top level (0) and 1st level subfolders (1)
51
+ try {
52
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
53
+ for (const entry of entries) {
54
+ const fullPath = path.join(currentPath, entry.name);
55
+ const relativePath = path.relative(rootPath, fullPath);
56
+ if (entry.isDirectory()) {
57
+ if (shouldIgnore(entry.name, relativePath, ignorePatterns))
58
+ continue;
59
+ if (shouldExclude(currentPath, fullPath))
60
+ continue;
61
+ scan(fullPath, depth + 1);
62
+ }
63
+ else if (entry.isFile()) {
64
+ const ext = path.extname(entry.name).toLowerCase();
65
+ const isMakefile = entry.name.toLowerCase() === 'makefile';
66
+ if (textExtensions.includes(ext) || isMakefile || ext === '') {
67
+ try {
68
+ const content = fs.readFileSync(fullPath, 'utf-8');
69
+ let match;
70
+ regex.lastIndex = 0;
71
+ while ((match = regex.exec(content)) !== null) {
72
+ variables.add(match[1]);
73
+ }
74
+ }
75
+ catch (e) {
76
+ // Skip files that can't be read or aren't text
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ catch (e) {
83
+ // Ignore directory read errors
84
+ }
85
+ };
86
+ scan(dirPath, 0);
87
+ return Array.from(variables);
88
+ }
89
+ /**
90
+ * Check if a file is executable (by extension or permissions)
91
+ */
92
+ export function isExecutable(fullPath, fileName) {
93
+ if (shouldExcludeFile(fileName))
94
+ return false;
95
+ const ext = path.extname(fileName).toLowerCase();
96
+ if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext))
97
+ return true;
98
+ if (fileName.toLowerCase() === 'makefile')
99
+ return true;
100
+ try {
101
+ const stat = fs.statSync(fullPath);
102
+ return !!(stat.mode & 0o111);
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /**
109
+ * Parse .info.md file for name and description
110
+ */
111
+ export function parseInfoFile(infoPath) {
112
+ let name = '';
113
+ let description = '';
114
+ if (fs.existsSync(infoPath)) {
115
+ const content = fs.readFileSync(infoPath, 'utf-8');
116
+ const lines = content.split('\n');
117
+ for (const line of lines) {
118
+ if (line.startsWith('# ')) {
119
+ name = line.substring(2).trim();
120
+ }
121
+ else if (line.trim() !== '' && !description && !line.startsWith('#')) {
122
+ description = line.trim();
123
+ }
124
+ }
125
+ }
126
+ return { name, description };
127
+ }
128
+ /**
129
+ * Load JSON template config from .pt-template.json or template.json
130
+ */
131
+ export function loadJsonTemplateConfig(dirPath) {
132
+ const jsonConfigPaths = [
133
+ path.join(dirPath, '.pt-template.json'),
134
+ path.join(dirPath, 'template.json')
135
+ ];
136
+ for (const jPath of jsonConfigPaths) {
137
+ if (fs.existsSync(jPath)) {
138
+ try {
139
+ const content = fs.readFileSync(jPath, 'utf-8');
140
+ return JSON.parse(content);
141
+ }
142
+ catch (e) {
143
+ console.warn(`Warning: Failed to parse ${path.basename(jPath)}: ${e.message}`);
144
+ }
145
+ }
146
+ }
147
+ return {};
148
+ }
149
+ /**
150
+ * Get root-level files and directories (for copy_files selection)
151
+ */
152
+ export function getRootEntries(dirPath, ignorePatterns) {
153
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
154
+ .filter(e => !shouldExclude(dirPath, path.join(dirPath, e.name), ignorePatterns))
155
+ .filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
156
+ const files = entries.filter(e => e.isFile()).map(e => e.name);
157
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
158
+ return { files, dirs };
159
+ }
160
+ /**
161
+ * Detect executable files at root level
162
+ */
163
+ export function detectRootExecutables(dirPath, ignorePatterns) {
164
+ const { files } = getRootEntries(dirPath, ignorePatterns);
165
+ return files.filter(file => isExecutable(path.join(dirPath, file), file));
166
+ }
167
+ /**
168
+ * Parse post_config.sh/.bat scripts for tasks
169
+ */
170
+ export function parsePostConfigScript(shPath, batPath) {
171
+ const tasks = [];
172
+ if (fs.existsSync(shPath)) {
173
+ const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
174
+ let currentDesc = '';
175
+ for (const line of lines) {
176
+ if (line.startsWith('echo "Running: ')) {
177
+ currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
178
+ }
179
+ else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
180
+ tasks.push({ command: line.trim(), description: currentDesc || line.trim() });
181
+ currentDesc = '';
182
+ }
183
+ }
184
+ }
185
+ else if (fs.existsSync(batPath)) {
186
+ const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
187
+ let currentDesc = '';
188
+ for (const line of lines) {
189
+ if (line.startsWith('echo Running: ')) {
190
+ currentDesc = line.substring(14).trim();
191
+ }
192
+ else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
193
+ tasks.push({ command: line.trim(), description: currentDesc || line.trim() });
194
+ currentDesc = '';
195
+ }
196
+ }
197
+ }
198
+ return tasks;
199
+ }
200
+ /**
201
+ * Merge post_config tasks from existing, JSON file, and detected scripts
202
+ */
203
+ export function mergePostConfigTasks(existingTasks, jsonTasks, detectedTasks) {
204
+ if (jsonTasks && Array.isArray(jsonTasks)) {
205
+ return [...jsonTasks];
206
+ }
207
+ return detectedTasks.length > 0 ? detectedTasks : existingTasks;
208
+ }
209
+ /**
210
+ * Merge post_copy files from existing, JSON file, and detected executables
211
+ */
212
+ export function mergePostCopyFiles(existingPostCopy, jsonPostCopy, detectedExecutables) {
213
+ let post_copy = [...existingPostCopy];
214
+ if (jsonPostCopy && Array.isArray(jsonPostCopy)) {
215
+ for (const pc of jsonPostCopy) {
216
+ if (!post_copy.some(existing => existing.src === pc.src)) {
217
+ post_copy.push(pc);
218
+ }
219
+ }
220
+ }
221
+ const newExecutables = detectedExecutables.filter(file => !post_copy.some(existing => existing.src === file));
222
+ if (newExecutables.length > 0) {
223
+ // In interactive mode, we'd prompt to add these - for now just auto-add
224
+ for (const file of newExecutables) {
225
+ post_copy.push({ src: file, dest: file });
226
+ }
227
+ }
228
+ return post_copy;
229
+ }
230
+ /**
231
+ * Build copy_files array from selected files/folders and existing entries
232
+ */
233
+ export function buildCopyFiles(selectedFiles, selectedFolders, existingCopyFiles) {
234
+ const copy_files = [...existingCopyFiles];
235
+ const existingSrcs = new Set(existingCopyFiles.map(e => e.src));
236
+ // Add new files
237
+ for (const f of selectedFiles) {
238
+ if (!existingSrcs.has(f)) {
239
+ copy_files.push({ src: f, dest: f, substitute_variables: true });
240
+ }
241
+ }
242
+ // Add new folder entries
243
+ for (const d of selectedFolders) {
244
+ if (!existingSrcs.has(d)) {
245
+ copy_files.push({ src: d, dest: d, substitute_variables: true });
246
+ }
247
+ }
248
+ return copy_files;
249
+ }
250
+ /**
251
+ * Prompt for template name with overwrite warning
252
+ */
253
+ export async function promptTemplateName(targetName, existingNames, autoDetectedName, options) {
254
+ if (autoDetectedName && !options.json) {
255
+ if (options.yes) {
256
+ if (existingNames.includes(targetName)) {
257
+ console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
258
+ }
259
+ }
260
+ else {
261
+ const { confirmName } = await inquirer.prompt({
262
+ type: 'confirm',
263
+ name: 'confirmName',
264
+ message: `Use "${targetName}" as the template name?`,
265
+ default: true
266
+ });
267
+ if (!confirmName) {
268
+ const { newName } = await inquirer.prompt({
269
+ type: 'input',
270
+ name: 'newName',
271
+ message: 'Name this template:',
272
+ default: targetName
273
+ });
274
+ targetName = newName;
275
+ }
276
+ }
277
+ }
278
+ return targetName;
279
+ }
280
+ /**
281
+ * Prompt for description
282
+ */
283
+ export async function promptDescription(defaultDesc, options) {
284
+ if (options.yes || options.json)
285
+ return defaultDesc;
286
+ const { newDesc } = await inquirer.prompt({
287
+ type: 'input',
288
+ name: 'newDesc',
289
+ message: 'Purpose/Description of this template:',
290
+ default: defaultDesc
291
+ });
292
+ return newDesc;
293
+ }
294
+ /**
295
+ * Prompt for additional variables (new template mode)
296
+ */
297
+ export async function promptAdditionalVariables(variables, options) {
298
+ if (options.yes || options.json)
299
+ return [];
300
+ const message = variables.length > 0
301
+ ? `Detected/Existing variables: ${variables.map(v => v.name).join(', ')}. Define more?`
302
+ : 'Define template variables (e.g., client_name, project_type)?';
303
+ const response = await inquirer.prompt({
304
+ type: 'confirm',
305
+ name: 'hasMoreVariables',
306
+ message: message,
307
+ default: false
308
+ });
309
+ if (!response.hasMoreVariables)
310
+ return [];
311
+ const { variableDefs } = await inquirer.prompt({
312
+ type: 'input',
313
+ name: 'variableDefs',
314
+ message: 'Define additional variables as comma-separated names:',
315
+ });
316
+ if (!variableDefs)
317
+ return [];
318
+ const additionalVars = variableDefs.split(',').map((v) => v.trim()).filter(Boolean);
319
+ return additionalVars
320
+ .filter(v => !variables.some(existing => existing.name === v))
321
+ .map(v => ({ name: v, prompt: `Enter ${v}:`, required: true }));
322
+ }
323
+ /**
324
+ * Prompt for new variables (additive mode)
325
+ */
326
+ export async function promptNewVariables(newVariables, options) {
327
+ if (newVariables.length === 0)
328
+ return [];
329
+ console.log(chalk.cyan(`\nšŸ“Š New Variables:`));
330
+ console.log(chalk.green(` + ${newVariables.length} new variable(s): ${newVariables.map(v => v.name).join(', ')}`));
331
+ if (options.yes || options.json)
332
+ return newVariables;
333
+ const { selectedVars } = await inquirer.prompt({
334
+ type: 'checkbox',
335
+ name: 'selectedVars',
336
+ message: 'Select variables to include (space to toggle):',
337
+ loop: false,
338
+ theme: {
339
+ icon: {
340
+ checked: chalk.green('[x] '),
341
+ unchecked: '[ ] ',
342
+ }
343
+ },
344
+ choices: newVariables.map(v => ({
345
+ name: v.name,
346
+ checked: true // Auto-select by default
347
+ }))
348
+ });
349
+ return newVariables.filter(v => selectedVars.includes(v.name));
350
+ }
351
+ /**
352
+ * Prompt for global variables
353
+ */
354
+ export async function promptGlobalVariables(globalVars, options) {
355
+ if (globalVars.length === 0 || options.yes || options.json) {
356
+ return options.yes || options.json ? globalVars : [];
357
+ }
358
+ const { selectedGlobals } = await inquirer.prompt({
359
+ type: 'checkbox',
360
+ name: 'selectedGlobals',
361
+ message: 'Select default/global variables to include (space to toggle):',
362
+ loop: false,
363
+ theme: {
364
+ icon: {
365
+ checked: chalk.green('[x] '),
366
+ unchecked: '[ ] ',
367
+ }
368
+ },
369
+ choices: globalVars.map(v => ({ name: v.name, checked: true }))
370
+ });
371
+ return globalVars.filter(v => selectedGlobals.includes(v.name));
372
+ }
373
+ /**
374
+ * Print "no new variables" message
375
+ */
376
+ export function printNoNewVariables() {
377
+ console.log(chalk.cyan("No new variables detected"));
378
+ }
379
+ /**
380
+ * Prompt for new folders (additive mode)
381
+ */
382
+ export async function promptNewFolders(newFolders, options) {
383
+ if (newFolders.length === 0)
384
+ return [];
385
+ console.log(chalk.cyan(`\nšŸ“Š New Folders:`));
386
+ console.log(chalk.green(` + ${newFolders.length} new folder(s): ${newFolders.map(f => f.name).join(', ')}`));
387
+ if (options.yes || options.json)
388
+ return newFolders;
389
+ const { selectedFolders } = await inquirer.prompt({
390
+ type: 'checkbox',
391
+ name: 'selectedFolders',
392
+ message: 'Select folders to include in template structure (space to toggle):',
393
+ loop: false,
394
+ theme: {
395
+ icon: {
396
+ checked: chalk.green('[x] '),
397
+ unchecked: '[ ] ',
398
+ }
399
+ },
400
+ choices: newFolders.map(f => ({
401
+ name: f.name,
402
+ checked: true // Auto-select by default
403
+ }))
404
+ });
405
+ return newFolders.filter(f => selectedFolders.includes(f.name));
406
+ }
407
+ /**
408
+ * Print "no new folders" message
409
+ */
410
+ export function printNoNewFolders() {
411
+ console.log(chalk.cyan("No new folders detected"));
412
+ }
413
+ /**
414
+ * Print new files message
415
+ */
416
+ export function printNewFiles(count, files) {
417
+ if (count > 0) {
418
+ console.log(chalk.cyan(`\nšŸ“Š New Files:`));
419
+ console.log(chalk.green(` + ${count} new file(s): ${files.join(', ')}`));
420
+ }
421
+ else {
422
+ console.log(chalk.cyan("No new files detected"));
423
+ }
424
+ }
425
+ /**
426
+ * Prompt for new files (additive mode)
427
+ */
428
+ export async function promptNewFiles(newFiles, options) {
429
+ if (newFiles.length === 0)
430
+ return [];
431
+ if (options.yes || options.json)
432
+ return newFiles;
433
+ const { selectedFileChoices } = await inquirer.prompt({
434
+ type: 'checkbox',
435
+ name: 'selectedFileChoices',
436
+ message: 'Select files to include (space to toggle):',
437
+ loop: false,
438
+ theme: {
439
+ icon: {
440
+ checked: chalk.green('[x] '),
441
+ unchecked: '[ ] ',
442
+ }
443
+ },
444
+ choices: newFiles.map(f => ({
445
+ name: f,
446
+ checked: true // Auto-select by default
447
+ }))
448
+ });
449
+ return newFiles.filter(f => selectedFileChoices.includes(f));
450
+ }
451
+ /**
452
+ * Prompt for root files (new template mode)
453
+ */
454
+ export async function promptRootFiles(rootFiles, defaultFiles, options) {
455
+ if (rootFiles.length === 0)
456
+ return [];
457
+ if (options.yes || options.json) {
458
+ const defaults = ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'];
459
+ return rootFiles.filter(f => defaults.some(p => f.toLowerCase() === p.toLowerCase()));
460
+ }
461
+ const filesResponse = await inquirer.prompt({
462
+ type: 'checkbox',
463
+ name: 'selectedFiles',
464
+ message: 'Select root files to include as boilerplate:',
465
+ loop: false,
466
+ theme: {
467
+ icon: {
468
+ checked: chalk.green('[x] '),
469
+ unchecked: '[ ] ',
470
+ }
471
+ },
472
+ choices: rootFiles.map(f => ({
473
+ name: f,
474
+ checked: ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase())
475
+ }))
476
+ });
477
+ return filesResponse.selectedFiles;
478
+ }
479
+ /**
480
+ * Prompt for structure folders (new template mode)
481
+ */
482
+ export async function promptStructureFolders(rootDirs, options) {
483
+ if (rootDirs.length === 0)
484
+ return [];
485
+ if (options.yes || options.json)
486
+ return rootDirs;
487
+ const foldersResponse = await inquirer.prompt({
488
+ type: 'checkbox',
489
+ name: 'selectedStructure',
490
+ message: 'Select folders to include in the template structure (skeleton):',
491
+ loop: false,
492
+ theme: {
493
+ icon: {
494
+ checked: chalk.green('[x] '),
495
+ unchecked: '[ ] ',
496
+ }
497
+ },
498
+ choices: rootDirs.map(d => ({
499
+ name: d,
500
+ checked: true // Include all in structure by default
501
+ }))
502
+ });
503
+ return foldersResponse.selectedStructure;
504
+ }
505
+ /**
506
+ * Prompt for copy folders (new template mode)
507
+ */
508
+ export async function promptCopyFolders(selectedStructure, defaultFolders, options) {
509
+ if (selectedStructure.length === 0)
510
+ return [];
511
+ if (options.yes || options.json) {
512
+ return selectedStructure.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
513
+ }
514
+ const copyFoldersResponse = await inquirer.prompt({
515
+ type: 'checkbox',
516
+ name: 'selectedFolders',
517
+ message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
518
+ loop: false,
519
+ theme: {
520
+ icon: {
521
+ checked: chalk.green('[x] '),
522
+ unchecked: '[ ] ',
523
+ }
524
+ },
525
+ choices: selectedStructure.map((d) => ({
526
+ name: d,
527
+ checked: ['APP', 'scripts', 'bin'].some(p => d === p)
528
+ }))
529
+ });
530
+ return copyFoldersResponse.selectedFolders;
531
+ }
532
+ /**
533
+ * Prompt for post-config tasks
534
+ */
535
+ export async function promptPostConfigTasks(tasks, options) {
536
+ if (tasks.length === 0)
537
+ return [];
538
+ if (options.yes || options.json) {
539
+ return tasks.map(t => t.command || t.script || '');
540
+ }
541
+ const choices = [];
542
+ for (const t of tasks) {
543
+ const cmd = t.command || t.script || '(no command)';
544
+ const desc = t.description ? ` (${t.description})` : '';
545
+ choices.push({
546
+ name: `${cmd}${desc}`,
547
+ value: cmd,
548
+ checked: t.checked !== false
549
+ });
550
+ }
551
+ const response = await inquirer.prompt({
552
+ type: 'checkbox',
553
+ name: 'selected',
554
+ message: 'Select default post-config tasks to include in this template:',
555
+ loop: false,
556
+ theme: {
557
+ icon: {
558
+ checked: chalk.green('[x] '),
559
+ unchecked: '[ ] ',
560
+ }
561
+ },
562
+ choices
563
+ });
564
+ return response.selected || [];
565
+ }