@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.
- package/README.md +112 -0
- package/dist/commands/addCommand.js +37 -0
- package/dist/commands/configCommand.js +96 -0
- package/dist/commands/ignoreCommand.js +12 -0
- package/dist/commands/initCommand.js +294 -0
- package/dist/commands/learnCommand.js +473 -0
- package/dist/commands/removeCommand.js +28 -0
- package/dist/commands/variablesCommand.js +62 -0
- package/dist/config.js +283 -0
- package/dist/index.js +75 -0
- package/dist/postconfig.js +73 -0
- package/dist/substitute.js +98 -0
- package/doc/configuration.md +333 -0
- package/doc/exclusions.md +35 -0
- package/doc/usage.md +119 -0
- package/doc/variable_substitution_example.md +78 -0
- package/package.json +36 -0
- package/skills/agency-pt-operator/SKILL.md +61 -0
- package/src/commands/addCommand.ts +41 -0
- package/src/commands/configCommand.ts +102 -0
- package/src/commands/ignoreCommand.ts +17 -0
- package/src/commands/initCommand.ts +311 -0
- package/src/commands/learnCommand.ts +492 -0
- package/src/commands/removeCommand.ts +35 -0
- package/src/commands/variablesCommand.ts +67 -0
- package/src/config.ts +356 -0
- package/src/index.ts +92 -0
- package/src/postconfig.ts +87 -0
- package/src/substitute.ts +122 -0
- package/tsconfig.json +16 -0
|
@@ -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
|
+
}
|