@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.
- package/.test-home-remote/.pt/config.yaml +10 -0
- package/README.md +85 -65
- package/dist/commands/initCommand.js +4 -4
- package/dist/commands/learnCommand.js +120 -432
- package/dist/commands/template-detection.js +256 -0
- package/dist/commands/template-prompts.js +332 -0
- package/dist/commands/template-utils.js +565 -0
- package/dist/commands/updateCommand.js +78 -532
- package/dist/config.js +38 -5
- package/dist/safety.js +203 -42
- package/doc/security.md +96 -36
- package/package.json +1 -1
- package/skills/agency-pt-operator/SKILL.md +54 -9
- package/src/commands/initCommand.ts +9 -9
- package/src/commands/learnCommand.ts +157 -423
- package/src/commands/template-detection.ts +269 -0
- package/src/commands/template-prompts.ts +406 -0
- package/src/commands/template-utils.ts +651 -0
- package/src/commands/updateCommand.ts +136 -577
- package/src/config.ts +29 -5
- package/src/safety.ts +239 -53
- package/tests/remote.test.ts +110 -0
- package/tests/safety.test.ts +296 -0
- package/tests/update.test.ts +417 -0
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
279
|
-
if (!options.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
name
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
314
|
-
const
|
|
256
|
+
// --- STRUCTURE ---
|
|
257
|
+
const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
315
258
|
? fileTemplateConfig.folders
|
|
316
259
|
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
317
260
|
|
|
318
|
-
//
|
|
319
|
-
const
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
280
|
+
printNoNewFolders();
|
|
281
|
+
folders = existingTemplate.folders;
|
|
378
282
|
}
|
|
379
283
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
copy_files
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
+
templateConfig.post_config.push(t);
|
|
502
352
|
}
|
|
503
353
|
}
|
|
504
354
|
}
|
|
505
355
|
}
|
|
506
356
|
}
|
|
507
357
|
|
|
508
|
-
//
|
|
509
|
-
const detectedExecutables
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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 (
|
|
518
|
-
templateConfig.post_copy =
|
|
519
|
-
const postCopySrcs =
|
|
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
|
-
|
|
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
|
+
}
|