@garyr/pt-cli 0.25.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,473 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import inquirer from 'inquirer';
4
+ import { loadConfig, saveConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, getDefaultPostConfig } from '../config.js';
5
+ import chalk from 'chalk';
6
+ export async function learn(sourcePath, updateTemplate = null, options = {}) {
7
+ const resolvedPath = path.resolve(sourcePath);
8
+ if (!fs.existsSync(resolvedPath)) {
9
+ console.error(chalk.red(`Error: Path "${sourcePath}" does not exist.`));
10
+ process.exit(1);
11
+ }
12
+ const isUpdate = !!updateTemplate;
13
+ const config = loadConfig();
14
+ const existingNames = getTemplateNames(config);
15
+ // Check for .info.md
16
+ let infoName = '';
17
+ let infoDesc = '';
18
+ const infoPath = path.join(resolvedPath, '.info.md');
19
+ if (fs.existsSync(infoPath)) {
20
+ const infoContent = fs.readFileSync(infoPath, 'utf-8');
21
+ const lines = infoContent.split('\n');
22
+ for (const line of lines) {
23
+ if (line.startsWith('# ')) {
24
+ infoName = line.substring(2).trim();
25
+ }
26
+ else if (line.trim() !== '' && !infoDesc && !line.startsWith('#')) {
27
+ infoDesc = line.trim();
28
+ }
29
+ }
30
+ }
31
+ let targetName = updateTemplate || '';
32
+ if (isUpdate) {
33
+ if (!targetName || !config.templates[targetName]) {
34
+ console.error(chalk.red(`Template "${targetName}" not found.`));
35
+ process.exit(1);
36
+ }
37
+ }
38
+ else {
39
+ if (options.name) {
40
+ targetName = options.name;
41
+ }
42
+ else if (infoName) {
43
+ targetName = infoName;
44
+ if (!options.json)
45
+ console.log(chalk.cyan(`Auto-detected template name from .info.md: ${targetName}`));
46
+ }
47
+ else {
48
+ if (options.yes || options.json) {
49
+ targetName = path.basename(resolvedPath);
50
+ }
51
+ else {
52
+ const { newName } = await inquirer.prompt({
53
+ type: 'input',
54
+ name: 'newName',
55
+ message: 'Name this template:',
56
+ default: path.basename(resolvedPath)
57
+ });
58
+ targetName = newName;
59
+ }
60
+ }
61
+ }
62
+ let description = '';
63
+ if (options.desc) {
64
+ description = options.desc;
65
+ }
66
+ else if (isUpdate) {
67
+ const currentDesc = config.templates[updateTemplate].description || '';
68
+ if (options.yes) {
69
+ description = currentDesc;
70
+ }
71
+ else {
72
+ const { changeDesc } = await inquirer.prompt({
73
+ type: 'confirm',
74
+ name: 'changeDesc',
75
+ message: `Change description from "${currentDesc}"?`,
76
+ default: false
77
+ });
78
+ if (!changeDesc) {
79
+ description = currentDesc;
80
+ }
81
+ else {
82
+ const { newDesc } = await inquirer.prompt({
83
+ type: 'input',
84
+ name: 'newDesc',
85
+ message: 'Purpose/Description of this template:',
86
+ default: currentDesc
87
+ });
88
+ description = newDesc;
89
+ }
90
+ }
91
+ }
92
+ else {
93
+ if (infoDesc) {
94
+ description = infoDesc;
95
+ if (!options.json)
96
+ console.log(chalk.cyan(`Auto-detected template description from .info.md: ${description}`));
97
+ }
98
+ else if (options.yes || options.json) {
99
+ description = infoDesc || '';
100
+ }
101
+ else {
102
+ const { newDesc } = await inquirer.prompt({
103
+ type: 'input',
104
+ name: 'newDesc',
105
+ message: 'Purpose/Description of this template:',
106
+ default: targetName
107
+ });
108
+ description = newDesc;
109
+ }
110
+ }
111
+ const cliIgnore = options.ignore ? options.ignore.split(',').map((s) => s.trim()).filter(Boolean) : [];
112
+ const ignorePatterns = [...(config.ignore || []), ...cliIgnore];
113
+ // Detect variables from files
114
+ const detectedVars = findVariablesInFiles(resolvedPath, resolvedPath, ignorePatterns);
115
+ if (detectedVars.length > 0 && !options.json) {
116
+ console.log(chalk.cyan(`Auto-detected ${detectedVars.length} variable(s): ${detectedVars.join(', ')}`));
117
+ }
118
+ let variables = [];
119
+ // Merge with existing variables if update
120
+ if (isUpdate && config.templates[updateTemplate].variables) {
121
+ variables = [...config.templates[updateTemplate].variables];
122
+ }
123
+ // Add detected variables if not already present
124
+ for (const varName of detectedVars) {
125
+ if (!variables.some(v => v.name === varName)) {
126
+ variables.push({
127
+ name: varName,
128
+ prompt: `Enter ${varName}:`,
129
+ required: true
130
+ });
131
+ }
132
+ }
133
+ // Include global variables as suggestions
134
+ if (config.variables && Array.isArray(config.variables)) {
135
+ for (const v of config.variables) {
136
+ if (!variables.some(existing => existing.name === v.name)) {
137
+ variables.push({ ...v });
138
+ }
139
+ }
140
+ }
141
+ let hasMoreVariables = false;
142
+ if (!options.yes && !options.json) {
143
+ const message = variables.length > 0
144
+ ? `Detected/Existing variables: ${variables.map(v => v.name).join(', ')}. Define more?`
145
+ : 'Define template variables (e.g., client_name, project_type)?';
146
+ const response = await inquirer.prompt({
147
+ type: 'confirm',
148
+ name: 'hasMoreVariables',
149
+ message: message,
150
+ default: false
151
+ });
152
+ hasMoreVariables = response.hasMoreVariables;
153
+ }
154
+ if (hasMoreVariables) {
155
+ const { variableDefs } = await inquirer.prompt({
156
+ type: 'input',
157
+ name: 'variableDefs',
158
+ message: 'Define additional variables as comma-separated names:',
159
+ });
160
+ if (variableDefs) {
161
+ const additionalVars = variableDefs.split(',').map((v) => v.trim()).filter(Boolean);
162
+ for (const v of additionalVars) {
163
+ if (!variables.some(existing => existing.name === v)) {
164
+ variables.push({
165
+ name: v,
166
+ prompt: `Enter ${v}:`,
167
+ required: true
168
+ });
169
+ }
170
+ }
171
+ }
172
+ }
173
+ // 1. Structure (skeleton)
174
+ const folders = extractStructure(resolvedPath, resolvedPath, ignorePatterns);
175
+ // 2. Content Selection (Root only)
176
+ const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
177
+ .filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
178
+ .filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
179
+ const rootFiles = rootEntries.filter(e => e.isFile()).map(e => e.name);
180
+ const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
181
+ let selectedFiles = [];
182
+ let selectedFolders = [];
183
+ let selectedStructure = [];
184
+ if (options.yes || options.json) {
185
+ // If --yes, auto-select the defaults
186
+ selectedFiles = rootFiles.filter(f => ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase()));
187
+ selectedStructure = rootDirs; // Include all folders in structure
188
+ selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p)); // Only copy specific ones recursively
189
+ }
190
+ else {
191
+ const filesResponse = await inquirer.prompt({
192
+ type: 'checkbox',
193
+ name: 'selectedFiles',
194
+ message: 'Select root files to include as boilerplate:',
195
+ loop: false,
196
+ theme: {
197
+ icon: {
198
+ checked: chalk.green('[x] '),
199
+ unchecked: '[ ] ',
200
+ }
201
+ },
202
+ choices: rootFiles.map(f => ({
203
+ name: f,
204
+ checked: ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase())
205
+ }))
206
+ });
207
+ selectedFiles = filesResponse.selectedFiles;
208
+ const foldersResponse = await inquirer.prompt({
209
+ type: 'checkbox',
210
+ name: 'selectedStructure',
211
+ message: 'Select folders to include in the template structure (skeleton):',
212
+ loop: false,
213
+ theme: {
214
+ icon: {
215
+ checked: chalk.green('[x] '),
216
+ unchecked: '[ ] ',
217
+ }
218
+ },
219
+ choices: rootDirs.map(d => ({
220
+ name: d,
221
+ checked: true // Include all in structure by default
222
+ }))
223
+ });
224
+ selectedStructure = foldersResponse.selectedStructure;
225
+ const copyFoldersResponse = await inquirer.prompt({
226
+ type: 'checkbox',
227
+ name: 'selectedFolders',
228
+ message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
229
+ loop: false,
230
+ theme: {
231
+ icon: {
232
+ checked: chalk.green('[x] '),
233
+ unchecked: '[ ] ',
234
+ }
235
+ },
236
+ choices: selectedStructure.map((d) => ({
237
+ name: d,
238
+ checked: ['APP', 'scripts', 'bin'].some(p => d === p)
239
+ }))
240
+ });
241
+ selectedFolders = copyFoldersResponse.selectedFolders;
242
+ }
243
+ const copy_files = [];
244
+ for (const f of selectedFiles) {
245
+ copy_files.push({ src: f, dest: f, substitute_variables: true });
246
+ }
247
+ for (const d of selectedFolders) {
248
+ copy_files.push({ src: d, dest: d, substitute_variables: true });
249
+ }
250
+ const templateConfig = {
251
+ description: description,
252
+ templateRoot: resolvedPath,
253
+ folders: folders.filter(f => selectedStructure.includes(f.name)),
254
+ copy_files: copy_files,
255
+ variables: variables.length > 0 ? variables : undefined
256
+ };
257
+ // Check for post_config scripts
258
+ const postConfigTasks = [];
259
+ const shPath = path.join(resolvedPath, 'post_config.sh');
260
+ const batPath = path.join(resolvedPath, 'post_config.bat');
261
+ if (fs.existsSync(shPath)) {
262
+ const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
263
+ let currentDesc = '';
264
+ for (const line of lines) {
265
+ if (line.startsWith('echo "Running: ')) {
266
+ currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
267
+ }
268
+ else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
269
+ postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
270
+ currentDesc = '';
271
+ }
272
+ }
273
+ }
274
+ else if (fs.existsSync(batPath)) {
275
+ const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
276
+ let currentDesc = '';
277
+ for (const line of lines) {
278
+ if (line.startsWith('echo Running: ')) {
279
+ currentDesc = line.substring(14).trim();
280
+ }
281
+ else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
282
+ postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
283
+ currentDesc = '';
284
+ }
285
+ }
286
+ }
287
+ if (postConfigTasks.length > 0) {
288
+ templateConfig.post_config = postConfigTasks;
289
+ if (!options.json)
290
+ console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
291
+ }
292
+ // Handle default_post_config tasks
293
+ const defaultPostConfig = getDefaultPostConfig(config);
294
+ const defaultApplicableTasks = defaultPostConfig.filter(t => !t.type || t.type === targetName);
295
+ if (defaultApplicableTasks.length > 0) {
296
+ let selectedTaskNames = [];
297
+ if (options.yes || options.json) {
298
+ selectedTaskNames = defaultApplicableTasks.map(t => t.command || t.script || '');
299
+ }
300
+ else {
301
+ const choices = [];
302
+ for (const t of defaultApplicableTasks) {
303
+ const cmd = t.command || t.script || '(no command)';
304
+ const desc = t.description ? ` (${t.description})` : '';
305
+ choices.push({
306
+ name: `${cmd}${desc}`,
307
+ value: cmd,
308
+ checked: t.checked !== false
309
+ });
310
+ }
311
+ const response = await inquirer.prompt({
312
+ type: 'checkbox',
313
+ name: 'selected',
314
+ message: 'Select default post-config tasks to include in this template:',
315
+ loop: false,
316
+ theme: {
317
+ icon: {
318
+ cursor: chalk.green('[x] ')
319
+ }
320
+ },
321
+ choices
322
+ });
323
+ selectedTaskNames = response.selected || [];
324
+ }
325
+ if (selectedTaskNames.length > 0) {
326
+ if (!templateConfig.post_config)
327
+ templateConfig.post_config = [];
328
+ for (const t of defaultApplicableTasks) {
329
+ const cmd = t.command || t.script || '';
330
+ if (selectedTaskNames.includes(cmd)) {
331
+ const alreadyExists = templateConfig.post_config.some(existing => existing.command === t.command && existing.script === t.script);
332
+ if (!alreadyExists) {
333
+ templateConfig.post_config.push(t);
334
+ }
335
+ }
336
+ }
337
+ }
338
+ }
339
+ // 3. Detect executables at root
340
+ const detectedExecutables = [];
341
+ for (const file of rootFiles) {
342
+ const fullPath = path.join(resolvedPath, file);
343
+ if (isExecutable(fullPath, file)) {
344
+ detectedExecutables.push(file);
345
+ }
346
+ }
347
+ if (detectedExecutables.length > 0) {
348
+ if (!options.json) {
349
+ console.log(chalk.cyan("\nAuto-detected " + detectedExecutables.length + " executable file(s) at root:"));
350
+ for (const file of detectedExecutables) {
351
+ console.log(chalk.gray(" - " + file));
352
+ }
353
+ }
354
+ let addPostCopy = true;
355
+ if (!options.yes && !options.json) {
356
+ const response = await inquirer.prompt({
357
+ type: 'confirm',
358
+ name: 'addPostCopy',
359
+ message: 'Add these to post_copy (auto-chmod)?',
360
+ default: true
361
+ });
362
+ addPostCopy = response.addPostCopy;
363
+ }
364
+ if (addPostCopy) {
365
+ templateConfig.post_copy = detectedExecutables.map(f => ({ src: f, dest: f }));
366
+ templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !detectedExecutables.includes(cf.src));
367
+ }
368
+ }
369
+ if (options.json) {
370
+ const output = {
371
+ name: targetName,
372
+ ...templateConfig
373
+ };
374
+ console.log(JSON.stringify(output, null, 2));
375
+ return;
376
+ }
377
+ config.templates[targetName] = templateConfig;
378
+ saveConfig(config);
379
+ console.log(chalk.green(`\n✓ Template saved as "${targetName}"`));
380
+ }
381
+ function isExecutable(fullPath, fileName) {
382
+ if (shouldExcludeFile(fileName))
383
+ return false;
384
+ const ext = path.extname(fileName).toLowerCase();
385
+ if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext))
386
+ return true;
387
+ if (fileName.toLowerCase() === 'makefile')
388
+ return true;
389
+ try {
390
+ const stat = fs.statSync(fullPath);
391
+ return !!(stat.mode & 0o111);
392
+ }
393
+ catch {
394
+ return false;
395
+ }
396
+ }
397
+ function extractStructure(dirPath, rootPath, ignorePatterns) {
398
+ let nodes = [];
399
+ try {
400
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
401
+ for (const entry of entries) {
402
+ const fullPath = path.join(dirPath, entry.name);
403
+ const relativePath = path.relative(rootPath, fullPath);
404
+ // Only include directories in the structure skeleton
405
+ const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
406
+ if (!isDirectory)
407
+ continue;
408
+ if (shouldIgnore(entry.name, relativePath, ignorePatterns))
409
+ continue;
410
+ if (shouldExclude(dirPath, fullPath))
411
+ continue;
412
+ const children = extractStructure(fullPath, rootPath, ignorePatterns);
413
+ let info = "";
414
+ const gitkeepPath = path.join(fullPath, '.gitkeep.md');
415
+ const infoPath = path.join(fullPath, '.info.md');
416
+ if (fs.existsSync(gitkeepPath))
417
+ info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
418
+ else if (fs.existsSync(infoPath))
419
+ info = fs.readFileSync(infoPath, 'utf-8').trim();
420
+ nodes.push({ name: entry.name, info: info, children: children });
421
+ }
422
+ }
423
+ catch (e) { }
424
+ return nodes;
425
+ }
426
+ /**
427
+ * Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
428
+ */
429
+ function findVariablesInFiles(dirPath, rootPath, ignorePatterns) {
430
+ const variables = new Set();
431
+ const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
432
+ const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
433
+ const scan = (currentPath, depth) => {
434
+ if (depth > 1)
435
+ return; // Top level (0) and 1st level subfolders (1)
436
+ try {
437
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
438
+ for (const entry of entries) {
439
+ const fullPath = path.join(currentPath, entry.name);
440
+ const relativePath = path.relative(rootPath, fullPath);
441
+ if (entry.isDirectory()) {
442
+ if (shouldIgnore(entry.name, relativePath, ignorePatterns))
443
+ continue;
444
+ if (shouldExclude(currentPath, fullPath))
445
+ continue;
446
+ scan(fullPath, depth + 1);
447
+ }
448
+ else if (entry.isFile()) {
449
+ const ext = path.extname(entry.name).toLowerCase();
450
+ const isMakefile = entry.name.toLowerCase() === 'makefile';
451
+ if (textExtensions.includes(ext) || isMakefile || ext === '') {
452
+ try {
453
+ const content = fs.readFileSync(fullPath, 'utf-8');
454
+ let match;
455
+ regex.lastIndex = 0;
456
+ while ((match = regex.exec(content)) !== null) {
457
+ variables.add(match[1]);
458
+ }
459
+ }
460
+ catch (e) {
461
+ // Skip files that can't be read or aren't text
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ catch (e) {
468
+ // Ignore directory read errors
469
+ }
470
+ };
471
+ scan(dirPath, 0);
472
+ return Array.from(variables);
473
+ }
@@ -0,0 +1,28 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { loadConfig, saveConfig } from '../config.js';
4
+ export async function removeCommand(templateName, options = {}) {
5
+ const config = loadConfig();
6
+ if (!config.templates[templateName]) {
7
+ console.error(chalk.red(`Template "${templateName}" not found.`));
8
+ process.exit(1);
9
+ }
10
+ let confirmRemoval = options.yes;
11
+ if (!confirmRemoval) {
12
+ const response = await inquirer.prompt({
13
+ type: 'confirm',
14
+ name: 'confirmRemoval',
15
+ message: `Are you sure you want to remove template "${templateName}"?`,
16
+ default: false
17
+ });
18
+ confirmRemoval = response.confirmRemoval;
19
+ }
20
+ if (confirmRemoval) {
21
+ delete config.templates[templateName];
22
+ saveConfig(config);
23
+ console.log(chalk.green(`✓ Template "${templateName}" removed.`));
24
+ }
25
+ else {
26
+ console.log(chalk.gray('Removal cancelled.'));
27
+ }
28
+ }
@@ -0,0 +1,62 @@
1
+ import fs from 'fs';
2
+ import { loadConfig, saveConfig } from '../config.js';
3
+ export function variablesCommand(pairs, options = {}) {
4
+ const config = loadConfig();
5
+ if (options.delete) {
6
+ if (config.variables) {
7
+ const index = config.variables.findIndex((v) => v.name === options.delete);
8
+ if (index !== -1) {
9
+ config.variables.splice(index, 1);
10
+ saveConfig(config);
11
+ console.log(`Global variable "${options.delete}" removed.`);
12
+ }
13
+ else {
14
+ console.log(`Global variable "${options.delete}" not found.`);
15
+ }
16
+ }
17
+ return;
18
+ }
19
+ if (options.set) {
20
+ if (options.json) {
21
+ try {
22
+ const data = options.json.startsWith('{') || options.json.startsWith('[')
23
+ ? JSON.parse(options.json)
24
+ : JSON.parse(fs.readFileSync(options.json, 'utf-8'));
25
+ config.variables = Array.isArray(data) ? data : [];
26
+ saveConfig(config);
27
+ console.log('Global variables updated via JSON.');
28
+ }
29
+ catch (e) {
30
+ const error = e;
31
+ console.error('Failed to parse JSON for variables:', error.message);
32
+ }
33
+ return;
34
+ }
35
+ if (!config.variables)
36
+ config.variables = [];
37
+ const parts = pairs ? pairs.split(',') : [];
38
+ for (const part of parts) {
39
+ const [k, ...v] = part.split('=');
40
+ if (k) {
41
+ const name = k.trim();
42
+ const val = v.join('=').trim();
43
+ const existing = config.variables.find((x) => x.name === name);
44
+ if (existing) {
45
+ existing.default = val;
46
+ }
47
+ else {
48
+ config.variables.push({
49
+ name,
50
+ prompt: `Enter ${name}:`,
51
+ default: val
52
+ });
53
+ }
54
+ }
55
+ }
56
+ saveConfig(config);
57
+ console.log('Global variables updated.');
58
+ }
59
+ else {
60
+ console.log('Current global variables:', config.variables || []);
61
+ }
62
+ }