@garyr/pt-cli 0.39.1 → 0.40.1
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/dist/commands/updateCommand.js +785 -0
- package/dist/index.js +3 -1
- package/doc/usage.md +4 -0
- package/package.json +1 -1
- package/skills/agency-pt-operator/SKILL.md +6 -2
- package/src/commands/updateCommand.ts +840 -0
- package/src/index.ts +3 -1
|
@@ -0,0 +1,840 @@
|
|
|
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, TemplateVariable, CopyFileEntry, PostConfigTask, getDefaultPostConfig } from '../config.js';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { downloadAndExtract } from '../remote.js';
|
|
7
|
+
|
|
8
|
+
export interface UpdateOptions {
|
|
9
|
+
ignore?: string;
|
|
10
|
+
yes?: boolean;
|
|
11
|
+
desc?: string;
|
|
12
|
+
json?: boolean;
|
|
13
|
+
allowUntrusted?: boolean;
|
|
14
|
+
noDiff?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Additive mode functions - only add new items, never remove existing
|
|
18
|
+
interface NewStructure {
|
|
19
|
+
added: FolderNode[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface NewFiles {
|
|
23
|
+
newFiles: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface NewVariables {
|
|
27
|
+
newVariables: TemplateVariable[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getNewFolders(storedStructure: FolderNode[], targetPath: string, ignorePatterns: string[]): NewStructure {
|
|
31
|
+
const added: FolderNode[] = [];
|
|
32
|
+
|
|
33
|
+
const getStructureMap = (nodes: FolderNode[]): Map<string, FolderNode> => {
|
|
34
|
+
const map = new Map<string, FolderNode>();
|
|
35
|
+
for (const node of nodes) {
|
|
36
|
+
map.set(node.name, node);
|
|
37
|
+
}
|
|
38
|
+
return map;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const storedMap = getStructureMap(storedStructure);
|
|
42
|
+
const targetStructure = extractStructure(targetPath, targetPath, ignorePatterns);
|
|
43
|
+
const targetMap = getStructureMap(targetStructure);
|
|
44
|
+
|
|
45
|
+
// Find only new folders
|
|
46
|
+
for (const [name, targetNode] of targetMap) {
|
|
47
|
+
if (!storedMap.has(name)) {
|
|
48
|
+
added.push(targetNode);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { added };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getNewFiles(storedTemplate: TemplateConfig, targetPath: string): NewFiles {
|
|
56
|
+
const newFiles: string[] = [];
|
|
57
|
+
|
|
58
|
+
// Get current files in target
|
|
59
|
+
const currentFiles = fs.readdirSync(targetPath, { withFileTypes: true })
|
|
60
|
+
.filter(e => e.isFile())
|
|
61
|
+
.map(e => e.name);
|
|
62
|
+
|
|
63
|
+
// Get files from stored template
|
|
64
|
+
const storedFiles = storedTemplate.copy_files?.map(f => f.src) || [];
|
|
65
|
+
|
|
66
|
+
// Find only new files
|
|
67
|
+
for (const file of currentFiles) {
|
|
68
|
+
if (!storedFiles.includes(file)) {
|
|
69
|
+
newFiles.push(file);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { newFiles };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getNewVariables(storedVariables: TemplateVariable[], targetPath: string): NewVariables {
|
|
77
|
+
const newVariables: TemplateVariable[] = [];
|
|
78
|
+
|
|
79
|
+
// Get variables from target directory
|
|
80
|
+
const targetVars = findVariablesInFiles(targetPath, targetPath);
|
|
81
|
+
const storedVarMap = new Set(storedVariables.map(v => v.name));
|
|
82
|
+
|
|
83
|
+
// Find only new variables
|
|
84
|
+
for (const varName of targetVars) {
|
|
85
|
+
if (!storedVarMap.has(varName)) {
|
|
86
|
+
newVariables.push({
|
|
87
|
+
name: varName,
|
|
88
|
+
prompt: `Enter ${varName}:`,
|
|
89
|
+
required: true
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { newVariables };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function update(sourcePath: string, templateName: string, options: UpdateOptions = {}): Promise<void> {
|
|
98
|
+
// Determine if additive mode is disabled
|
|
99
|
+
const isFullMode = options.noDiff;
|
|
100
|
+
|
|
101
|
+
let resolvedPath: string;
|
|
102
|
+
|
|
103
|
+
// Phase 1: Remote Check
|
|
104
|
+
if (sourcePath.startsWith('http')) {
|
|
105
|
+
console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
|
|
106
|
+
try {
|
|
107
|
+
resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if ((err as Error).message === 'Download cancelled by user due to untrusted source') {
|
|
110
|
+
console.log(chalk.yellow('Download cancelled. Exiting.'));
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
resolvedPath = path.resolve(sourcePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
120
|
+
console.error(chalk.red(`Error: Path "${resolvedPath}" does not exist.`));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const config = loadConfig();
|
|
125
|
+
if (!config.templates[templateName]) {
|
|
126
|
+
console.error(chalk.red(`Template "${templateName}" not found.`));
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check for template configuration JSON file (.pt-template.json or template.json)
|
|
131
|
+
let fileTemplateConfig: Partial<TemplateConfig> & { name?: string } = {};
|
|
132
|
+
const jsonConfigPaths = [
|
|
133
|
+
path.join(resolvedPath, '.pt-template.json'),
|
|
134
|
+
path.join(resolvedPath, 'template.json')
|
|
135
|
+
];
|
|
136
|
+
for (const jPath of jsonConfigPaths) {
|
|
137
|
+
if (fs.existsSync(jPath)) {
|
|
138
|
+
try {
|
|
139
|
+
const fileContent = fs.readFileSync(jPath, 'utf-8');
|
|
140
|
+
fileTemplateConfig = JSON.parse(fileContent);
|
|
141
|
+
if (!options.json) console.log(chalk.cyan(`Auto-detected template configurations from ${path.basename(jPath)}`));
|
|
142
|
+
break;
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.warn(chalk.yellow(`Warning: Failed to parse ${path.basename(jPath)}: ${(e as Error).message}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check for .info.md
|
|
150
|
+
let infoName = '';
|
|
151
|
+
let infoDesc = '';
|
|
152
|
+
const infoPath = path.join(resolvedPath, '.info.md');
|
|
153
|
+
if (fs.existsSync(infoPath)) {
|
|
154
|
+
const infoContent = fs.readFileSync(infoPath, 'utf-8');
|
|
155
|
+
const lines = infoContent.split('\n');
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (line.startsWith('# ')) {
|
|
158
|
+
infoName = line.substring(2).trim();
|
|
159
|
+
} else if (line.trim() !== '' && !infoDesc && !line.startsWith('#')) {
|
|
160
|
+
infoDesc = line.trim();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let description = config.templates[templateName].description || '';
|
|
166
|
+
let templateRoot = config.templates[templateName].templateRoot || resolvedPath;
|
|
167
|
+
|
|
168
|
+
if (!options.yes && !options.json) {
|
|
169
|
+
const answers = await inquirer.prompt([
|
|
170
|
+
{
|
|
171
|
+
type: 'input',
|
|
172
|
+
name: 'description',
|
|
173
|
+
message: 'Template description:',
|
|
174
|
+
default: description
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: 'input',
|
|
178
|
+
name: 'templateRoot',
|
|
179
|
+
message: 'Template root folder:',
|
|
180
|
+
default: templateRoot
|
|
181
|
+
}
|
|
182
|
+
]);
|
|
183
|
+
description = answers.description;
|
|
184
|
+
templateRoot = answers.templateRoot;
|
|
185
|
+
if (!fs.existsSync(templateRoot)) {
|
|
186
|
+
console.error(chalk.red(`Error: Template root path "${templateRoot}" does not exist.`));
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
if (options.desc) {
|
|
191
|
+
description = options.desc;
|
|
192
|
+
} else if (fileTemplateConfig.description) {
|
|
193
|
+
description = fileTemplateConfig.description;
|
|
194
|
+
} else if (infoDesc) {
|
|
195
|
+
description = infoDesc;
|
|
196
|
+
}
|
|
197
|
+
templateRoot = resolvedPath;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const cliIgnore = options.ignore ? options.ignore.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
|
|
201
|
+
const ignorePatterns = [...(config.ignore || []), ...cliIgnore];
|
|
202
|
+
|
|
203
|
+
// Detect variables from files
|
|
204
|
+
const detectedVars = findVariablesInFiles(resolvedPath, resolvedPath, ignorePatterns);
|
|
205
|
+
if (detectedVars.length > 0 && !options.json) {
|
|
206
|
+
console.log(chalk.cyan(`Auto-detected ${detectedVars.length} variable(s): ${detectedVars.join(', ')}`));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let variables: TemplateVariable[] = [];
|
|
210
|
+
|
|
211
|
+
// During updates, merge existing template variables with JSON file variables
|
|
212
|
+
if (config.templates[templateName].variables) {
|
|
213
|
+
variables = [...config.templates[templateName].variables];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Then add JSON variables (overwrite/update existing ones with same name)
|
|
217
|
+
if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
|
|
218
|
+
for (const v of fileTemplateConfig.variables) {
|
|
219
|
+
const existingIndex = variables.findIndex(existing => existing.name === v.name);
|
|
220
|
+
if (existingIndex !== -1) {
|
|
221
|
+
// Update existing variable with JSON values (but preserve other fields)
|
|
222
|
+
variables[existingIndex] = { ...variables[existingIndex], ...v };
|
|
223
|
+
} else {
|
|
224
|
+
// Add new variable
|
|
225
|
+
variables.push({ ...v });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Add detected variables if not already present
|
|
231
|
+
for (const varName of detectedVars) {
|
|
232
|
+
if (!variables.some(v => v.name === varName)) {
|
|
233
|
+
variables.push({
|
|
234
|
+
name: varName,
|
|
235
|
+
prompt: `Enter ${varName}:`,
|
|
236
|
+
required: true
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Variable handling - additive mode
|
|
242
|
+
if (!isFullMode) {
|
|
243
|
+
// Additive mode: only present new variables for selection
|
|
244
|
+
const variableDiff = getNewVariables(config.templates[templateName].variables || [], resolvedPath);
|
|
245
|
+
|
|
246
|
+
// Also include default/global variables that are not already in the template
|
|
247
|
+
const globalVarsToPrompt: TemplateVariable[] = [];
|
|
248
|
+
if (config.variables && Array.isArray(config.variables)) {
|
|
249
|
+
for (const v of config.variables) {
|
|
250
|
+
if (!variables.some(existing => existing.name === v.name) &&
|
|
251
|
+
!variableDiff.newVariables.some(existing => existing.name === v.name)) {
|
|
252
|
+
globalVarsToPrompt.push({ ...v });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const combinedNewVars = [...variableDiff.newVariables, ...globalVarsToPrompt];
|
|
258
|
+
|
|
259
|
+
if (combinedNewVars.length > 0) {
|
|
260
|
+
console.log(chalk.cyan(`\n📊 New Variables:`));
|
|
261
|
+
console.log(chalk.green(` + ${combinedNewVars.length} new variable(s): ${combinedNewVars.map(v => v.name).join(', ')}`));
|
|
262
|
+
|
|
263
|
+
// Show interactive checkbox for new variables
|
|
264
|
+
if (!options.yes && !options.json) {
|
|
265
|
+
const { selectedVars } = await inquirer.prompt({
|
|
266
|
+
type: 'checkbox',
|
|
267
|
+
name: 'selectedVars',
|
|
268
|
+
message: 'Select variables to include (space to toggle):',
|
|
269
|
+
loop: false,
|
|
270
|
+
theme: {
|
|
271
|
+
icon: {
|
|
272
|
+
checked: chalk.green('[x] '),
|
|
273
|
+
unchecked: '[ ] ',
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
choices: combinedNewVars.map(v => ({
|
|
277
|
+
name: v.name,
|
|
278
|
+
checked: true // Auto-select by default
|
|
279
|
+
}))
|
|
280
|
+
});
|
|
281
|
+
// Only add selected variables
|
|
282
|
+
variables.push(...combinedNewVars.filter(v => selectedVars.includes(v.name)));
|
|
283
|
+
} else {
|
|
284
|
+
// In --yes or --json mode, auto-add all
|
|
285
|
+
variables.push(...combinedNewVars);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
console.log(chalk.cyan("No new variables detected"));
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
// Full mode: original behavior with optional default/global variables prompt
|
|
292
|
+
const globalVarsToPrompt: TemplateVariable[] = [];
|
|
293
|
+
if (config.variables && Array.isArray(config.variables)) {
|
|
294
|
+
for (const v of config.variables) {
|
|
295
|
+
if (!variables.some(existing => existing.name === v.name)) {
|
|
296
|
+
globalVarsToPrompt.push({ ...v });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (globalVarsToPrompt.length > 0) {
|
|
302
|
+
if (!options.yes && !options.json) {
|
|
303
|
+
const { selectedGlobals } = await inquirer.prompt({
|
|
304
|
+
type: 'checkbox',
|
|
305
|
+
name: 'selectedGlobals',
|
|
306
|
+
message: 'Select default/global variables to include (space to toggle):',
|
|
307
|
+
loop: false,
|
|
308
|
+
theme: {
|
|
309
|
+
icon: {
|
|
310
|
+
checked: chalk.green('[x] '),
|
|
311
|
+
unchecked: '[ ] ',
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
choices: globalVarsToPrompt.map(v => ({ name: v.name, checked: true }))
|
|
315
|
+
});
|
|
316
|
+
variables.push(...globalVarsToPrompt.filter(v => selectedGlobals.includes(v.name)));
|
|
317
|
+
} else {
|
|
318
|
+
variables.push(...globalVarsToPrompt);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let hasMoreVariables = false;
|
|
323
|
+
if (!options.yes && !options.json) {
|
|
324
|
+
const message = variables.length > 0
|
|
325
|
+
? `Detected/Existing variables: ${variables.map(v => v.name).join(', ')}. Define more?`
|
|
326
|
+
: 'Define template variables (e.g., client_name, project_type)?';
|
|
327
|
+
|
|
328
|
+
const response = await inquirer.prompt({
|
|
329
|
+
type: 'confirm',
|
|
330
|
+
name: 'hasMoreVariables',
|
|
331
|
+
message: message,
|
|
332
|
+
default: false
|
|
333
|
+
});
|
|
334
|
+
hasMoreVariables = response.hasMoreVariables;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (hasMoreVariables) {
|
|
338
|
+
const { variableDefs } = await inquirer.prompt({
|
|
339
|
+
type: 'input',
|
|
340
|
+
name: 'variableDefs',
|
|
341
|
+
message: 'Define additional variables as comma-separated names:',
|
|
342
|
+
});
|
|
343
|
+
if (variableDefs) {
|
|
344
|
+
const additionalVars = (variableDefs as string).split(',').map((v: string) => v.trim()).filter(Boolean);
|
|
345
|
+
for (const v of additionalVars) {
|
|
346
|
+
if (!variables.some(existing => existing.name === v)) {
|
|
347
|
+
variables.push({
|
|
348
|
+
name: v,
|
|
349
|
+
prompt: `Enter ${v}:`,
|
|
350
|
+
required: true
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 1. Structure (skeleton) - Additive mode
|
|
359
|
+
let folders: FolderNode[] = [];
|
|
360
|
+
if (!isFullMode) {
|
|
361
|
+
// Additive mode: only add new folders
|
|
362
|
+
const structureDiff = getNewFolders(config.templates[templateName].folders, resolvedPath, ignorePatterns);
|
|
363
|
+
|
|
364
|
+
if (structureDiff.added.length > 0) {
|
|
365
|
+
console.log(chalk.cyan(`\n📊 New Folders:`));
|
|
366
|
+
console.log(chalk.green(` + ${structureDiff.added.length} new folder(s): ${structureDiff.added.map(f => f.name).join(', ')}`));
|
|
367
|
+
|
|
368
|
+
// Show interactive checkbox for new folders
|
|
369
|
+
if (!options.yes && !options.json) {
|
|
370
|
+
const { selectedFolders } = await inquirer.prompt({
|
|
371
|
+
type: 'checkbox',
|
|
372
|
+
name: 'selectedFolders',
|
|
373
|
+
message: 'Select folders to include in template structure (space to toggle):',
|
|
374
|
+
loop: false,
|
|
375
|
+
theme: {
|
|
376
|
+
icon: {
|
|
377
|
+
checked: chalk.green('[x] '),
|
|
378
|
+
unchecked: '[ ] ',
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
choices: structureDiff.added.map(f => ({
|
|
382
|
+
name: f.name,
|
|
383
|
+
checked: true // Auto-select by default
|
|
384
|
+
}))
|
|
385
|
+
});
|
|
386
|
+
// Only add selected folders
|
|
387
|
+
folders = [...config.templates[templateName].folders, ...structureDiff.added.filter(f => selectedFolders.includes(f.name))];
|
|
388
|
+
} else {
|
|
389
|
+
// In --yes or --json mode, auto-add all
|
|
390
|
+
folders = [...config.templates[templateName].folders, ...structureDiff.added];
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
console.log(chalk.cyan("No new folders detected"));
|
|
394
|
+
folders = config.templates[templateName].folders;
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
// Full mode: original behavior
|
|
398
|
+
folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
399
|
+
? fileTemplateConfig.folders
|
|
400
|
+
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 2. Content Selection (Root only) - Additive mode
|
|
404
|
+
const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
|
405
|
+
.filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
|
|
406
|
+
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
407
|
+
|
|
408
|
+
const rootFiles = rootEntries.filter(e => e.isFile()).map(e => e.name);
|
|
409
|
+
const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
410
|
+
|
|
411
|
+
let selectedFiles: string[] = [];
|
|
412
|
+
let selectedFolders: string[] = [];
|
|
413
|
+
let selectedStructure: string[] = [];
|
|
414
|
+
|
|
415
|
+
if (!isFullMode) {
|
|
416
|
+
// Additive mode for files and folders
|
|
417
|
+
// Start with existing copy_files entries as the base (preserves all settings including substitute_variables)
|
|
418
|
+
const existingCopyFiles = config.templates[templateName].copy_files || [];
|
|
419
|
+
// selectedFiles here is only used to track new file names to append
|
|
420
|
+
let newSelectedFiles: string[] = [];
|
|
421
|
+
let newSelectedFolderDirs: string[] = [];
|
|
422
|
+
|
|
423
|
+
const fileDiff = getNewFiles(config.templates[templateName], resolvedPath);
|
|
424
|
+
|
|
425
|
+
if (fileDiff.newFiles.length > 0) {
|
|
426
|
+
console.log(chalk.cyan(`\n📊 New Files:`));
|
|
427
|
+
console.log(chalk.green(` + ${fileDiff.newFiles.length} new file(s): ${fileDiff.newFiles.join(', ')}`));
|
|
428
|
+
|
|
429
|
+
// Show interactive checkbox for new files
|
|
430
|
+
if (!options.yes && !options.json) {
|
|
431
|
+
const { selectedFileChoices } = await inquirer.prompt({
|
|
432
|
+
type: 'checkbox',
|
|
433
|
+
name: 'selectedFileChoices',
|
|
434
|
+
message: 'Select files to include (space to toggle):',
|
|
435
|
+
loop: false,
|
|
436
|
+
theme: {
|
|
437
|
+
icon: {
|
|
438
|
+
checked: chalk.green('[x] '),
|
|
439
|
+
unchecked: '[ ] ',
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
choices: fileDiff.newFiles.map(f => ({
|
|
443
|
+
name: f,
|
|
444
|
+
checked: true // Auto-select by default
|
|
445
|
+
}))
|
|
446
|
+
});
|
|
447
|
+
newSelectedFiles = fileDiff.newFiles.filter(f => selectedFileChoices.includes(f));
|
|
448
|
+
} else {
|
|
449
|
+
// In --yes or --json mode, auto-add all
|
|
450
|
+
newSelectedFiles = [...fileDiff.newFiles];
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
console.log(chalk.cyan("No new files detected"));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// For folders, use additive mode logic
|
|
457
|
+
// Preserve existing folder structure
|
|
458
|
+
selectedStructure = config.templates[templateName].folders?.map(f => f.name) || [];
|
|
459
|
+
// Seed selectedFolders from existing copy_files directory entries (not a hardcoded list)
|
|
460
|
+
selectedFolders = existingCopyFiles
|
|
461
|
+
.filter(f => rootDirs.includes(f.src))
|
|
462
|
+
.map(f => f.src);
|
|
463
|
+
|
|
464
|
+
if (rootDirs.length > 0) {
|
|
465
|
+
const structureDiff = getNewFolders(config.templates[templateName].folders, resolvedPath, ignorePatterns);
|
|
466
|
+
|
|
467
|
+
if (structureDiff.added.length > 0) {
|
|
468
|
+
// Auto-select added folders for structure
|
|
469
|
+
selectedStructure = [...new Set([...selectedStructure, ...structureDiff.added.map(f => f.name)])];
|
|
470
|
+
// For new dirs, auto-select for recursive copy if they match the standard list
|
|
471
|
+
newSelectedFolderDirs = structureDiff.added
|
|
472
|
+
.filter(f => ['APP', 'scripts', 'bin'].some(p => f.name === p))
|
|
473
|
+
.map(f => f.name);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Merge: existing folder copy_files + newly selected dirs
|
|
477
|
+
selectedFolders = [...new Set([...selectedFolders, ...newSelectedFolderDirs])];
|
|
478
|
+
// Also carry forward file names for use in copy_files construction below
|
|
479
|
+
selectedFiles = [...existingCopyFiles.filter(f => !rootDirs.includes(f.src)).map(f => f.src), ...newSelectedFiles];
|
|
480
|
+
} else {
|
|
481
|
+
// Full mode: original behavior
|
|
482
|
+
if (options.yes || options.json) {
|
|
483
|
+
// If --yes, auto-select the defaults
|
|
484
|
+
selectedFiles = rootFiles.filter(f => ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase()));
|
|
485
|
+
selectedStructure = rootDirs; // Include all folders in structure
|
|
486
|
+
selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p)); // Only copy specific ones recursively
|
|
487
|
+
} else {
|
|
488
|
+
if (rootFiles.length > 0) {
|
|
489
|
+
const filesResponse = await inquirer.prompt({
|
|
490
|
+
type: 'checkbox',
|
|
491
|
+
name: 'selectedFiles',
|
|
492
|
+
message: 'Select root files to include as boilerplate:',
|
|
493
|
+
loop: false,
|
|
494
|
+
theme: {
|
|
495
|
+
icon: {
|
|
496
|
+
checked: chalk.green('[x] '),
|
|
497
|
+
unchecked: '[ ] ',
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
choices: rootFiles.map(f => ({
|
|
501
|
+
name: f,
|
|
502
|
+
checked: ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase())
|
|
503
|
+
}))
|
|
504
|
+
});
|
|
505
|
+
selectedFiles = filesResponse.selectedFiles;
|
|
506
|
+
} else {
|
|
507
|
+
selectedFiles = [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (rootDirs.length > 0) {
|
|
511
|
+
const foldersResponse = await inquirer.prompt({
|
|
512
|
+
type: 'checkbox',
|
|
513
|
+
name: 'selectedStructure',
|
|
514
|
+
message: 'Select folders to include in the template structure (skeleton):',
|
|
515
|
+
loop: false,
|
|
516
|
+
theme: {
|
|
517
|
+
icon: {
|
|
518
|
+
checked: chalk.green('[x] '),
|
|
519
|
+
unchecked: '[ ] ',
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
choices: rootDirs.map(d => ({
|
|
523
|
+
name: d,
|
|
524
|
+
checked: true // Include all in structure by default
|
|
525
|
+
}))
|
|
526
|
+
});
|
|
527
|
+
selectedStructure = foldersResponse.selectedStructure;
|
|
528
|
+
} else {
|
|
529
|
+
selectedStructure = [];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (selectedStructure.length > 0) {
|
|
533
|
+
const copyFoldersResponse = await inquirer.prompt({
|
|
534
|
+
type: 'checkbox',
|
|
535
|
+
name: 'selectedFolders',
|
|
536
|
+
message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
|
|
537
|
+
loop: false,
|
|
538
|
+
theme: {
|
|
539
|
+
icon: {
|
|
540
|
+
checked: chalk.green('[x] '),
|
|
541
|
+
unchecked: '[ ] ',
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
choices: selectedStructure.map((d: string) => ({
|
|
545
|
+
name: d,
|
|
546
|
+
checked: ['APP', 'scripts', 'bin'].some(p => d === p)
|
|
547
|
+
}))
|
|
548
|
+
});
|
|
549
|
+
selectedFolders = copyFoldersResponse.selectedFolders;
|
|
550
|
+
} else {
|
|
551
|
+
selectedFolders = [];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const copy_files: CopyFileEntry[] = [];
|
|
557
|
+
if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
|
|
558
|
+
copy_files.push(...fileTemplateConfig.copy_files);
|
|
559
|
+
} else if (!isFullMode) {
|
|
560
|
+
// Additive mode: start directly from the existing copy_files to preserve all settings,
|
|
561
|
+
// then append only brand-new entries (never rebuild from scratch).
|
|
562
|
+
const existingCopyFiles = config.templates[templateName].copy_files || [];
|
|
563
|
+
const existingSrcs = new Set(existingCopyFiles.map(e => e.src));
|
|
564
|
+
|
|
565
|
+
// Begin with all existing entries untouched
|
|
566
|
+
copy_files.push(...existingCopyFiles);
|
|
567
|
+
|
|
568
|
+
// Append new files that were selected by the user
|
|
569
|
+
for (const f of selectedFiles) {
|
|
570
|
+
if (existingSrcs.has(f)) continue; // already present, skip
|
|
571
|
+
copy_files.push({ src: f, dest: f, substitute_variables: true });
|
|
572
|
+
}
|
|
573
|
+
// Append new directory entries that were selected
|
|
574
|
+
for (const d of selectedFolders) {
|
|
575
|
+
if (existingSrcs.has(d)) continue; // already present, skip
|
|
576
|
+
copy_files.push({ src: d, dest: d, substitute_variables: true });
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
// Full mode: build from scratch using the selected files/folders
|
|
580
|
+
for (const f of selectedFiles) {
|
|
581
|
+
copy_files.push({ src: f, dest: f, substitute_variables: true });
|
|
582
|
+
}
|
|
583
|
+
for (const d of selectedFolders) {
|
|
584
|
+
copy_files.push({ src: d, dest: d, substitute_variables: true });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const templateConfig: TemplateConfig = {
|
|
589
|
+
description: description,
|
|
590
|
+
templateRoot: templateRoot,
|
|
591
|
+
folders: fileTemplateConfig.folders ? folders : folders.filter(f => selectedStructure.includes(f.name)),
|
|
592
|
+
copy_files: copy_files,
|
|
593
|
+
variables: variables.length > 0 ? variables : undefined
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// Check for post_config scripts
|
|
597
|
+
let postConfigTasks: PostConfigTask[] = [];
|
|
598
|
+
if (fileTemplateConfig.post_config && Array.isArray(fileTemplateConfig.post_config)) {
|
|
599
|
+
postConfigTasks = [...fileTemplateConfig.post_config];
|
|
600
|
+
} else {
|
|
601
|
+
const shPath = path.join(resolvedPath, 'post_config.sh');
|
|
602
|
+
const batPath = path.join(resolvedPath, 'post_config.bat');
|
|
603
|
+
if (fs.existsSync(shPath)) {
|
|
604
|
+
const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
|
|
605
|
+
let currentDesc = '';
|
|
606
|
+
for (const line of lines) {
|
|
607
|
+
if (line.startsWith('echo "Running: ')) {
|
|
608
|
+
currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
|
|
609
|
+
} else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
|
|
610
|
+
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
611
|
+
currentDesc = '';
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} else if (fs.existsSync(batPath)) {
|
|
615
|
+
const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
|
|
616
|
+
let currentDesc = '';
|
|
617
|
+
for (const line of lines) {
|
|
618
|
+
if (line.startsWith('echo Running: ')) {
|
|
619
|
+
currentDesc = line.substring(14).trim();
|
|
620
|
+
} else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
|
|
621
|
+
postConfigTasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
622
|
+
currentDesc = '';
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (postConfigTasks.length > 0) {
|
|
628
|
+
templateConfig.post_config = postConfigTasks;
|
|
629
|
+
if (!options.json && !fileTemplateConfig.post_config) console.log(chalk.cyan(`Auto-detected ${postConfigTasks.length} post_config action(s) from script.`));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Handle default_post_config tasks
|
|
633
|
+
const defaultPostConfig = getDefaultPostConfig(config);
|
|
634
|
+
const defaultApplicableTasks = defaultPostConfig.filter(t => !t.type || t.type === templateName);
|
|
635
|
+
|
|
636
|
+
if (defaultApplicableTasks.length > 0) {
|
|
637
|
+
let selectedTaskNames: string[] = [];
|
|
638
|
+
if (options.yes || options.json) {
|
|
639
|
+
selectedTaskNames = defaultApplicableTasks.map(t => t.command || t.script || '');
|
|
640
|
+
} else {
|
|
641
|
+
const choices: Array<{name: string; value: string; checked?: boolean}> = [];
|
|
642
|
+
for (const t of defaultApplicableTasks) {
|
|
643
|
+
const cmd = t.command || t.script || '(no command)';
|
|
644
|
+
const desc = t.description ? ` (${t.description})` : '';
|
|
645
|
+
choices.push({
|
|
646
|
+
name: `${cmd}${desc}`,
|
|
647
|
+
value: cmd,
|
|
648
|
+
checked: t.checked !== false
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const response = await inquirer.prompt({
|
|
652
|
+
type: 'checkbox',
|
|
653
|
+
name: 'selected',
|
|
654
|
+
message: 'Select default post-config tasks to include in this template:',
|
|
655
|
+
loop: false,
|
|
656
|
+
theme: {
|
|
657
|
+
icon: {
|
|
658
|
+
checked: chalk.green('[x] '),
|
|
659
|
+
unchecked: '[ ] ',
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
choices
|
|
663
|
+
});
|
|
664
|
+
selectedTaskNames = response.selected || [];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (selectedTaskNames.length > 0) {
|
|
668
|
+
if (!templateConfig.post_config) templateConfig.post_config = [];
|
|
669
|
+
for (const t of defaultApplicableTasks) {
|
|
670
|
+
const cmd = t.command || t.script || '';
|
|
671
|
+
if (selectedTaskNames.includes(cmd)) {
|
|
672
|
+
const alreadyExists = templateConfig.post_config.some(existing => existing.command === t.command && existing.script === t.script);
|
|
673
|
+
if (!alreadyExists) {
|
|
674
|
+
templateConfig.post_config.push(t);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// 3. Detect executables at root
|
|
682
|
+
const detectedExecutables: string[] = [];
|
|
683
|
+
for (const file of rootFiles) {
|
|
684
|
+
const fullPath = path.join(resolvedPath, file);
|
|
685
|
+
if (isExecutable(fullPath, file)) {
|
|
686
|
+
detectedExecutables.push(file);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const existingPostCopy = config.templates[templateName].post_copy || [];
|
|
691
|
+
let post_copy = [...existingPostCopy];
|
|
692
|
+
|
|
693
|
+
if (fileTemplateConfig.post_copy && Array.isArray(fileTemplateConfig.post_copy)) {
|
|
694
|
+
for (const pc of fileTemplateConfig.post_copy) {
|
|
695
|
+
if (!post_copy.some(existing => existing.src === pc.src)) {
|
|
696
|
+
post_copy.push(pc);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const newExecutables = detectedExecutables.filter(file => !post_copy.some(existing => existing.src === file));
|
|
702
|
+
|
|
703
|
+
if (newExecutables.length > 0) {
|
|
704
|
+
if (!options.json) {
|
|
705
|
+
console.log(chalk.cyan("\nAuto-detected " + newExecutables.length + " new executable file(s) at root:"));
|
|
706
|
+
for (const file of newExecutables) {
|
|
707
|
+
console.log(chalk.gray(" - " + file));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
let addPostCopy = true;
|
|
711
|
+
if (!options.yes && !options.json) {
|
|
712
|
+
const response = await inquirer.prompt({
|
|
713
|
+
type: 'confirm',
|
|
714
|
+
name: 'addPostCopy',
|
|
715
|
+
message: 'Add these to post_copy (auto-chmod)?',
|
|
716
|
+
default: true
|
|
717
|
+
});
|
|
718
|
+
addPostCopy = response.addPostCopy;
|
|
719
|
+
}
|
|
720
|
+
if (addPostCopy) {
|
|
721
|
+
for (const file of newExecutables) {
|
|
722
|
+
post_copy.push({ src: file, dest: file });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (post_copy.length > 0) {
|
|
728
|
+
templateConfig.post_copy = post_copy;
|
|
729
|
+
const postCopySrcs = post_copy.map(f => f.src);
|
|
730
|
+
templateConfig.copy_files = templateConfig.copy_files?.filter(cf => !postCopySrcs.includes(cf.src));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (options.json) {
|
|
734
|
+
const output = {
|
|
735
|
+
name: templateName,
|
|
736
|
+
...templateConfig
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// Force the application to wait until every single byte of this JSON string
|
|
740
|
+
// safely clears the operating system's pipe buffer before letting the process die.
|
|
741
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
|
|
742
|
+
process.exit(0);
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
config.templates[templateName] = templateConfig;
|
|
748
|
+
saveConfig(config);
|
|
749
|
+
|
|
750
|
+
console.log(chalk.green(`\n✓ Template saved as "${templateName}"`));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function isExecutable(fullPath: string, fileName: string): boolean {
|
|
754
|
+
if (shouldExcludeFile(fileName)) return false;
|
|
755
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
756
|
+
if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext)) return true;
|
|
757
|
+
if (fileName.toLowerCase() === 'makefile') return true;
|
|
758
|
+
try {
|
|
759
|
+
const stat = fs.statSync(fullPath);
|
|
760
|
+
return !!(stat.mode & 0o111);
|
|
761
|
+
} catch {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function extractStructure(dirPath: string, rootPath: string, ignorePatterns?: string[]): FolderNode[] {
|
|
767
|
+
let nodes: FolderNode[] = [];
|
|
768
|
+
try {
|
|
769
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
770
|
+
for (const entry of entries) {
|
|
771
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
772
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
773
|
+
|
|
774
|
+
// Only include directories in the structure skeleton
|
|
775
|
+
const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
|
|
776
|
+
if (!isDirectory) continue;
|
|
777
|
+
|
|
778
|
+
if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
|
|
779
|
+
if (shouldExclude(dirPath, fullPath)) continue;
|
|
780
|
+
|
|
781
|
+
const children = extractStructure(fullPath, rootPath, ignorePatterns);
|
|
782
|
+
let info = "";
|
|
783
|
+
const gitkeepPath = path.join(fullPath, '.gitkeep.md');
|
|
784
|
+
const infoPath = path.join(fullPath, '.info.md');
|
|
785
|
+
if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
|
|
786
|
+
else if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
|
|
787
|
+
nodes.push({ name: entry.name, info: info, children: children });
|
|
788
|
+
}
|
|
789
|
+
} catch (e) {}
|
|
790
|
+
return nodes;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
|
|
795
|
+
*/
|
|
796
|
+
function findVariablesInFiles(dirPath: string, rootPath: string, ignorePatterns?: string[]): string[] {
|
|
797
|
+
const variables = new Set<string>();
|
|
798
|
+
const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
799
|
+
|
|
800
|
+
const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
|
|
801
|
+
|
|
802
|
+
const scan = (currentPath: string, depth: number) => {
|
|
803
|
+
if (depth > 1) return; // Top level (0) and 1st level subfolders (1)
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
807
|
+
for (const entry of entries) {
|
|
808
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
809
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
810
|
+
|
|
811
|
+
if (entry.isDirectory()) {
|
|
812
|
+
if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
|
|
813
|
+
if (shouldExclude(currentPath, fullPath)) continue;
|
|
814
|
+
scan(fullPath, depth + 1);
|
|
815
|
+
} else if (entry.isFile()) {
|
|
816
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
817
|
+
const isMakefile = entry.name.toLowerCase() === 'makefile';
|
|
818
|
+
|
|
819
|
+
if (textExtensions.includes(ext) || isMakefile || ext === '') {
|
|
820
|
+
try {
|
|
821
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
822
|
+
let match;
|
|
823
|
+
regex.lastIndex = 0;
|
|
824
|
+
while ((match = regex.exec(content)) !== null) {
|
|
825
|
+
variables.add(match[1]);
|
|
826
|
+
}
|
|
827
|
+
} catch (e) {
|
|
828
|
+
// Skip files that can't be read or aren't text
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} catch (e) {
|
|
834
|
+
// Ignore directory read errors
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
scan(dirPath, 0);
|
|
839
|
+
return Array.from(variables);
|
|
840
|
+
}
|