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