@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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { FolderNode, TemplateConfig, CopyFileEntry, PostConfigTask, PostCopyFile, TemplateVariable, shouldIgnore, shouldExclude, shouldExcludeFile } from '../config.js';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract folder structure skeleton from a directory
|
|
9
|
+
* Only includes directories, with optional .info.md content
|
|
10
|
+
*/
|
|
11
|
+
export function extractStructure(dirPath: string, rootPath: string, ignorePatterns?: string[]): FolderNode[] {
|
|
12
|
+
const nodes: FolderNode[] = [];
|
|
13
|
+
try {
|
|
14
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
17
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
18
|
+
|
|
19
|
+
// Only include directories in the structure skeleton
|
|
20
|
+
const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
|
|
21
|
+
if (!isDirectory) continue;
|
|
22
|
+
|
|
23
|
+
if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
|
|
24
|
+
if (shouldExclude(dirPath, fullPath)) continue;
|
|
25
|
+
|
|
26
|
+
const children = extractStructure(fullPath, rootPath, ignorePatterns);
|
|
27
|
+
let info = '';
|
|
28
|
+
const gitkeepPath = path.join(fullPath, '.gitkeep.md');
|
|
29
|
+
const infoPath = path.join(fullPath, '.info.md');
|
|
30
|
+
if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
|
|
31
|
+
else if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
|
|
32
|
+
nodes.push({ name: entry.name, info, children });
|
|
33
|
+
}
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// Ignore directory read errors
|
|
36
|
+
}
|
|
37
|
+
return nodes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
|
|
42
|
+
*/
|
|
43
|
+
export function findVariablesInFiles(dirPath: string, rootPath: string, ignorePatterns?: string[]): string[] {
|
|
44
|
+
const variables = new Set<string>();
|
|
45
|
+
const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
46
|
+
|
|
47
|
+
const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
|
|
48
|
+
|
|
49
|
+
const scan = (currentPath: string, depth: number) => {
|
|
50
|
+
if (depth > 1) return; // Top level (0) and 1st level subfolders (1)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
56
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
57
|
+
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
|
|
60
|
+
if (shouldExclude(currentPath, fullPath)) continue;
|
|
61
|
+
scan(fullPath, depth + 1);
|
|
62
|
+
} else if (entry.isFile()) {
|
|
63
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
64
|
+
const isMakefile = entry.name.toLowerCase() === 'makefile';
|
|
65
|
+
|
|
66
|
+
if (textExtensions.includes(ext) || isMakefile || ext === '') {
|
|
67
|
+
try {
|
|
68
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
69
|
+
let match;
|
|
70
|
+
regex.lastIndex = 0;
|
|
71
|
+
while ((match = regex.exec(content)) !== null) {
|
|
72
|
+
variables.add(match[1]);
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// Skip files that can't be read or aren't text
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Ignore directory read errors
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
scan(dirPath, 0);
|
|
86
|
+
return Array.from(variables);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a file is executable (by extension or permissions)
|
|
91
|
+
*/
|
|
92
|
+
export function isExecutable(fullPath: string, fileName: string): boolean {
|
|
93
|
+
if (shouldExcludeFile(fileName)) return false;
|
|
94
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
95
|
+
if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext)) return true;
|
|
96
|
+
if (fileName.toLowerCase() === 'makefile') return true;
|
|
97
|
+
try {
|
|
98
|
+
const stat = fs.statSync(fullPath);
|
|
99
|
+
return !!(stat.mode & 0o111);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse .info.md file for name and description
|
|
107
|
+
*/
|
|
108
|
+
export function parseInfoFile(infoPath: string): { name: string; description: string } {
|
|
109
|
+
let name = '';
|
|
110
|
+
let description = '';
|
|
111
|
+
if (fs.existsSync(infoPath)) {
|
|
112
|
+
const content = fs.readFileSync(infoPath, 'utf-8');
|
|
113
|
+
const lines = content.split('\n');
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (line.startsWith('# ')) {
|
|
116
|
+
name = line.substring(2).trim();
|
|
117
|
+
} else if (line.trim() !== '' && !description && !line.startsWith('#')) {
|
|
118
|
+
description = line.trim();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { name, description };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Load JSON template config from .pt-template.json or template.json
|
|
127
|
+
*/
|
|
128
|
+
export function loadJsonTemplateConfig(dirPath: string): Partial<TemplateConfig> & { name?: string } {
|
|
129
|
+
const jsonConfigPaths = [
|
|
130
|
+
path.join(dirPath, '.pt-template.json'),
|
|
131
|
+
path.join(dirPath, 'template.json')
|
|
132
|
+
];
|
|
133
|
+
for (const jPath of jsonConfigPaths) {
|
|
134
|
+
if (fs.existsSync(jPath)) {
|
|
135
|
+
try {
|
|
136
|
+
const content = fs.readFileSync(jPath, 'utf-8');
|
|
137
|
+
return JSON.parse(content);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.warn(`Warning: Failed to parse ${path.basename(jPath)}: ${(e as Error).message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get root-level files and directories (for copy_files selection)
|
|
148
|
+
*/
|
|
149
|
+
export function getRootEntries(dirPath: string, ignorePatterns?: string[]): { files: string[]; dirs: string[] } {
|
|
150
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
151
|
+
.filter(e => !shouldExclude(dirPath, path.join(dirPath, e.name), ignorePatterns))
|
|
152
|
+
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
153
|
+
|
|
154
|
+
const files = entries.filter(e => e.isFile()).map(e => e.name);
|
|
155
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
156
|
+
return { files, dirs };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Detect executable files at root level
|
|
161
|
+
*/
|
|
162
|
+
export function detectRootExecutables(dirPath: string, ignorePatterns?: string[]): string[] {
|
|
163
|
+
const { files } = getRootEntries(dirPath, ignorePatterns);
|
|
164
|
+
return files.filter(file => isExecutable(path.join(dirPath, file), file));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse post_config.sh/.bat scripts for tasks
|
|
169
|
+
*/
|
|
170
|
+
export function parsePostConfigScript(shPath: string, batPath: string): PostConfigTask[] {
|
|
171
|
+
const tasks: PostConfigTask[] = [];
|
|
172
|
+
|
|
173
|
+
if (fs.existsSync(shPath)) {
|
|
174
|
+
const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
|
|
175
|
+
let currentDesc = '';
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
if (line.startsWith('echo "Running: ')) {
|
|
178
|
+
currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
|
|
179
|
+
} else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
|
|
180
|
+
tasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
181
|
+
currentDesc = '';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else if (fs.existsSync(batPath)) {
|
|
185
|
+
const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
|
|
186
|
+
let currentDesc = '';
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
if (line.startsWith('echo Running: ')) {
|
|
189
|
+
currentDesc = line.substring(14).trim();
|
|
190
|
+
} else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
|
|
191
|
+
tasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
192
|
+
currentDesc = '';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return tasks;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Merge post_config tasks from existing, JSON file, and detected scripts
|
|
201
|
+
*/
|
|
202
|
+
export function mergePostConfigTasks(
|
|
203
|
+
existingTasks: PostConfigTask[],
|
|
204
|
+
jsonTasks: PostConfigTask[] | undefined,
|
|
205
|
+
detectedTasks: PostConfigTask[]
|
|
206
|
+
): PostConfigTask[] {
|
|
207
|
+
if (jsonTasks && Array.isArray(jsonTasks)) {
|
|
208
|
+
return [...jsonTasks];
|
|
209
|
+
}
|
|
210
|
+
return detectedTasks.length > 0 ? detectedTasks : existingTasks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Merge post_copy files from existing, JSON file, and detected executables
|
|
215
|
+
*/
|
|
216
|
+
export function mergePostCopyFiles(
|
|
217
|
+
existingPostCopy: PostCopyFile[],
|
|
218
|
+
jsonPostCopy: PostCopyFile[] | undefined,
|
|
219
|
+
detectedExecutables: string[]
|
|
220
|
+
): PostCopyFile[] {
|
|
221
|
+
let post_copy = [...existingPostCopy];
|
|
222
|
+
|
|
223
|
+
if (jsonPostCopy && Array.isArray(jsonPostCopy)) {
|
|
224
|
+
for (const pc of jsonPostCopy) {
|
|
225
|
+
if (!post_copy.some(existing => existing.src === pc.src)) {
|
|
226
|
+
post_copy.push(pc);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const newExecutables = detectedExecutables.filter(file => !post_copy.some(existing => existing.src === file));
|
|
232
|
+
|
|
233
|
+
if (newExecutables.length > 0) {
|
|
234
|
+
// In interactive mode, we'd prompt to add these - for now just auto-add
|
|
235
|
+
for (const file of newExecutables) {
|
|
236
|
+
post_copy.push({ src: file, dest: file });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return post_copy;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Build copy_files array from selected files/folders and existing entries
|
|
245
|
+
*/
|
|
246
|
+
export function buildCopyFiles(
|
|
247
|
+
selectedFiles: string[],
|
|
248
|
+
selectedFolders: string[],
|
|
249
|
+
existingCopyFiles: CopyFileEntry[]
|
|
250
|
+
): CopyFileEntry[] {
|
|
251
|
+
const copy_files: CopyFileEntry[] = [...existingCopyFiles];
|
|
252
|
+
const existingSrcs = new Set(existingCopyFiles.map(e => e.src));
|
|
253
|
+
|
|
254
|
+
// Add new files
|
|
255
|
+
for (const f of selectedFiles) {
|
|
256
|
+
if (!existingSrcs.has(f)) {
|
|
257
|
+
copy_files.push({ src: f, dest: f, substitute_variables: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Add new folder entries
|
|
261
|
+
for (const d of selectedFolders) {
|
|
262
|
+
if (!existingSrcs.has(d)) {
|
|
263
|
+
copy_files.push({ src: d, dest: d, substitute_variables: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return copy_files;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Prompt for template name with overwrite warning
|
|
271
|
+
*/
|
|
272
|
+
export async function promptTemplateName(
|
|
273
|
+
targetName: string,
|
|
274
|
+
existingNames: string[],
|
|
275
|
+
autoDetectedName: string | null,
|
|
276
|
+
options: { yes?: boolean; json?: boolean }
|
|
277
|
+
): Promise<string> {
|
|
278
|
+
if (autoDetectedName && !options.json) {
|
|
279
|
+
if (options.yes) {
|
|
280
|
+
if (existingNames.includes(targetName)) {
|
|
281
|
+
console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
const { confirmName } = await inquirer.prompt({
|
|
285
|
+
type: 'confirm',
|
|
286
|
+
name: 'confirmName',
|
|
287
|
+
message: `Use "${targetName}" as the template name?`,
|
|
288
|
+
default: true
|
|
289
|
+
});
|
|
290
|
+
if (!confirmName) {
|
|
291
|
+
const { newName } = await inquirer.prompt({
|
|
292
|
+
type: 'input',
|
|
293
|
+
name: 'newName',
|
|
294
|
+
message: 'Name this template:',
|
|
295
|
+
default: targetName
|
|
296
|
+
});
|
|
297
|
+
targetName = newName;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return targetName;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Prompt for description
|
|
306
|
+
*/
|
|
307
|
+
export async function promptDescription(
|
|
308
|
+
defaultDesc: string,
|
|
309
|
+
options: { yes?: boolean; json?: boolean }
|
|
310
|
+
): Promise<string> {
|
|
311
|
+
if (options.yes || options.json) return defaultDesc;
|
|
312
|
+
|
|
313
|
+
const { newDesc } = await inquirer.prompt({
|
|
314
|
+
type: 'input',
|
|
315
|
+
name: 'newDesc',
|
|
316
|
+
message: 'Purpose/Description of this template:',
|
|
317
|
+
default: defaultDesc
|
|
318
|
+
});
|
|
319
|
+
return newDesc;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Prompt for additional variables (new template mode)
|
|
324
|
+
*/
|
|
325
|
+
export async function promptAdditionalVariables(
|
|
326
|
+
variables: TemplateVariable[],
|
|
327
|
+
options: { yes?: boolean; json?: boolean }
|
|
328
|
+
): Promise<TemplateVariable[]> {
|
|
329
|
+
if (options.yes || options.json) return [];
|
|
330
|
+
|
|
331
|
+
const message = variables.length > 0
|
|
332
|
+
? `Detected/Existing variables: ${variables.map(v => v.name).join(', ')}. Define more?`
|
|
333
|
+
: 'Define template variables (e.g., client_name, project_type)?';
|
|
334
|
+
|
|
335
|
+
const response = await inquirer.prompt({
|
|
336
|
+
type: 'confirm',
|
|
337
|
+
name: 'hasMoreVariables',
|
|
338
|
+
message: message,
|
|
339
|
+
default: false
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (!response.hasMoreVariables) return [];
|
|
343
|
+
|
|
344
|
+
const { variableDefs } = await inquirer.prompt({
|
|
345
|
+
type: 'input',
|
|
346
|
+
name: 'variableDefs',
|
|
347
|
+
message: 'Define additional variables as comma-separated names:',
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!variableDefs) return [];
|
|
351
|
+
|
|
352
|
+
const additionalVars = (variableDefs as string).split(',').map((v: string) => v.trim()).filter(Boolean);
|
|
353
|
+
return additionalVars
|
|
354
|
+
.filter(v => !variables.some(existing => existing.name === v))
|
|
355
|
+
.map(v => ({ name: v, prompt: `Enter ${v}:`, required: true }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Prompt for new variables (additive mode)
|
|
360
|
+
*/
|
|
361
|
+
export async function promptNewVariables(
|
|
362
|
+
newVariables: TemplateVariable[],
|
|
363
|
+
options: { yes?: boolean; json?: boolean }
|
|
364
|
+
): Promise<TemplateVariable[]> {
|
|
365
|
+
if (newVariables.length === 0) return [];
|
|
366
|
+
|
|
367
|
+
console.log(chalk.cyan(`\n📊 New Variables:`));
|
|
368
|
+
console.log(chalk.green(` + ${newVariables.length} new variable(s): ${newVariables.map(v => v.name).join(', ')}`));
|
|
369
|
+
|
|
370
|
+
if (options.yes || options.json) return newVariables;
|
|
371
|
+
|
|
372
|
+
const { selectedVars } = await inquirer.prompt({
|
|
373
|
+
type: 'checkbox',
|
|
374
|
+
name: 'selectedVars',
|
|
375
|
+
message: 'Select variables to include (space to toggle):',
|
|
376
|
+
loop: false,
|
|
377
|
+
theme: {
|
|
378
|
+
icon: {
|
|
379
|
+
checked: chalk.green('[x] '),
|
|
380
|
+
unchecked: '[ ] ',
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
choices: newVariables.map(v => ({
|
|
384
|
+
name: v.name,
|
|
385
|
+
checked: true // Auto-select by default
|
|
386
|
+
}))
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return newVariables.filter(v => selectedVars.includes(v.name));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Prompt for global variables
|
|
394
|
+
*/
|
|
395
|
+
export async function promptGlobalVariables(
|
|
396
|
+
globalVars: TemplateVariable[],
|
|
397
|
+
options: { yes?: boolean; json?: boolean }
|
|
398
|
+
): Promise<TemplateVariable[]> {
|
|
399
|
+
if (globalVars.length === 0 || options.yes || options.json) {
|
|
400
|
+
return options.yes || options.json ? globalVars : [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const { selectedGlobals } = await inquirer.prompt({
|
|
404
|
+
type: 'checkbox',
|
|
405
|
+
name: 'selectedGlobals',
|
|
406
|
+
message: 'Select default/global variables to include (space to toggle):',
|
|
407
|
+
loop: false,
|
|
408
|
+
theme: {
|
|
409
|
+
icon: {
|
|
410
|
+
checked: chalk.green('[x] '),
|
|
411
|
+
unchecked: '[ ] ',
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
choices: globalVars.map(v => ({ name: v.name, checked: true }))
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return globalVars.filter(v => selectedGlobals.includes(v.name));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Print "no new variables" message
|
|
422
|
+
*/
|
|
423
|
+
export function printNoNewVariables(): void {
|
|
424
|
+
console.log(chalk.cyan("No new variables detected"));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Prompt for new folders (additive mode)
|
|
429
|
+
*/
|
|
430
|
+
export async function promptNewFolders(
|
|
431
|
+
newFolders: FolderNode[],
|
|
432
|
+
options: { yes?: boolean; json?: boolean }
|
|
433
|
+
): Promise<FolderNode[]> {
|
|
434
|
+
if (newFolders.length === 0) return [];
|
|
435
|
+
|
|
436
|
+
console.log(chalk.cyan(`\n📊 New Folders:`));
|
|
437
|
+
console.log(chalk.green(` + ${newFolders.length} new folder(s): ${newFolders.map(f => f.name).join(', ')}`));
|
|
438
|
+
|
|
439
|
+
if (options.yes || options.json) return newFolders;
|
|
440
|
+
|
|
441
|
+
const { selectedFolders } = await inquirer.prompt({
|
|
442
|
+
type: 'checkbox',
|
|
443
|
+
name: 'selectedFolders',
|
|
444
|
+
message: 'Select folders to include in template structure (space to toggle):',
|
|
445
|
+
loop: false,
|
|
446
|
+
theme: {
|
|
447
|
+
icon: {
|
|
448
|
+
checked: chalk.green('[x] '),
|
|
449
|
+
unchecked: '[ ] ',
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
choices: newFolders.map(f => ({
|
|
453
|
+
name: f.name,
|
|
454
|
+
checked: true // Auto-select by default
|
|
455
|
+
}))
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
return newFolders.filter(f => selectedFolders.includes(f.name));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Print "no new folders" message
|
|
463
|
+
*/
|
|
464
|
+
export function printNoNewFolders(): void {
|
|
465
|
+
console.log(chalk.cyan("No new folders detected"));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Print new files message
|
|
470
|
+
*/
|
|
471
|
+
export function printNewFiles(count: number, files: string[]): void {
|
|
472
|
+
if (count > 0) {
|
|
473
|
+
console.log(chalk.cyan(`\n📊 New Files:`));
|
|
474
|
+
console.log(chalk.green(` + ${count} new file(s): ${files.join(', ')}`));
|
|
475
|
+
} else {
|
|
476
|
+
console.log(chalk.cyan("No new files detected"));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Prompt for new files (additive mode)
|
|
482
|
+
*/
|
|
483
|
+
export async function promptNewFiles(
|
|
484
|
+
newFiles: string[],
|
|
485
|
+
options: { yes?: boolean; json?: boolean }
|
|
486
|
+
): Promise<string[]> {
|
|
487
|
+
if (newFiles.length === 0) return [];
|
|
488
|
+
|
|
489
|
+
if (options.yes || options.json) return newFiles;
|
|
490
|
+
|
|
491
|
+
const { selectedFileChoices } = await inquirer.prompt({
|
|
492
|
+
type: 'checkbox',
|
|
493
|
+
name: 'selectedFileChoices',
|
|
494
|
+
message: 'Select files to include (space to toggle):',
|
|
495
|
+
loop: false,
|
|
496
|
+
theme: {
|
|
497
|
+
icon: {
|
|
498
|
+
checked: chalk.green('[x] '),
|
|
499
|
+
unchecked: '[ ] ',
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
choices: newFiles.map(f => ({
|
|
503
|
+
name: f,
|
|
504
|
+
checked: true // Auto-select by default
|
|
505
|
+
}))
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
return newFiles.filter(f => selectedFileChoices.includes(f));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Prompt for root files (new template mode)
|
|
513
|
+
*/
|
|
514
|
+
export async function promptRootFiles(
|
|
515
|
+
rootFiles: string[],
|
|
516
|
+
defaultFiles: string[] | undefined,
|
|
517
|
+
options: { yes?: boolean; json?: boolean }
|
|
518
|
+
): Promise<string[]> {
|
|
519
|
+
if (rootFiles.length === 0) return [];
|
|
520
|
+
|
|
521
|
+
if (options.yes || options.json) {
|
|
522
|
+
const defaults = ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'];
|
|
523
|
+
return rootFiles.filter(f => defaults.some(p => f.toLowerCase() === p.toLowerCase()));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const filesResponse = await inquirer.prompt({
|
|
527
|
+
type: 'checkbox',
|
|
528
|
+
name: 'selectedFiles',
|
|
529
|
+
message: 'Select root files to include as boilerplate:',
|
|
530
|
+
loop: false,
|
|
531
|
+
theme: {
|
|
532
|
+
icon: {
|
|
533
|
+
checked: chalk.green('[x] '),
|
|
534
|
+
unchecked: '[ ] ',
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
choices: rootFiles.map(f => ({
|
|
538
|
+
name: f,
|
|
539
|
+
checked: ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase())
|
|
540
|
+
}))
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return filesResponse.selectedFiles;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Prompt for structure folders (new template mode)
|
|
548
|
+
*/
|
|
549
|
+
export async function promptStructureFolders(
|
|
550
|
+
rootDirs: string[],
|
|
551
|
+
options: { yes?: boolean; json?: boolean }
|
|
552
|
+
): Promise<string[]> {
|
|
553
|
+
if (rootDirs.length === 0) return [];
|
|
554
|
+
|
|
555
|
+
if (options.yes || options.json) return rootDirs;
|
|
556
|
+
|
|
557
|
+
const foldersResponse = await inquirer.prompt({
|
|
558
|
+
type: 'checkbox',
|
|
559
|
+
name: 'selectedStructure',
|
|
560
|
+
message: 'Select folders to include in the template structure (skeleton):',
|
|
561
|
+
loop: false,
|
|
562
|
+
theme: {
|
|
563
|
+
icon: {
|
|
564
|
+
checked: chalk.green('[x] '),
|
|
565
|
+
unchecked: '[ ] ',
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
choices: rootDirs.map(d => ({
|
|
569
|
+
name: d,
|
|
570
|
+
checked: true // Include all in structure by default
|
|
571
|
+
}))
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
return foldersResponse.selectedStructure;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Prompt for copy folders (new template mode)
|
|
579
|
+
*/
|
|
580
|
+
export async function promptCopyFolders(
|
|
581
|
+
selectedStructure: string[],
|
|
582
|
+
defaultFolders: string[] | undefined,
|
|
583
|
+
options: { yes?: boolean; json?: boolean }
|
|
584
|
+
): Promise<string[]> {
|
|
585
|
+
if (selectedStructure.length === 0) return [];
|
|
586
|
+
|
|
587
|
+
if (options.yes || options.json) {
|
|
588
|
+
return selectedStructure.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const copyFoldersResponse = await inquirer.prompt({
|
|
592
|
+
type: 'checkbox',
|
|
593
|
+
name: 'selectedFolders',
|
|
594
|
+
message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
|
|
595
|
+
loop: false,
|
|
596
|
+
theme: {
|
|
597
|
+
icon: {
|
|
598
|
+
checked: chalk.green('[x] '),
|
|
599
|
+
unchecked: '[ ] ',
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
choices: selectedStructure.map((d: string) => ({
|
|
603
|
+
name: d,
|
|
604
|
+
checked: ['APP', 'scripts', 'bin'].some(p => d === p)
|
|
605
|
+
}))
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
return copyFoldersResponse.selectedFolders;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Prompt for post-config tasks
|
|
613
|
+
*/
|
|
614
|
+
export async function promptPostConfigTasks(
|
|
615
|
+
tasks: PostConfigTask[],
|
|
616
|
+
options: { yes?: boolean; json?: boolean }
|
|
617
|
+
): Promise<string[]> {
|
|
618
|
+
if (tasks.length === 0) return [];
|
|
619
|
+
|
|
620
|
+
if (options.yes || options.json) {
|
|
621
|
+
return tasks.map(t => t.command || t.script || '');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const choices: Array<{name: string; value: string; checked?: boolean}> = [];
|
|
625
|
+
|
|
626
|
+
for (const t of tasks) {
|
|
627
|
+
const cmd = t.command || t.script || '(no command)';
|
|
628
|
+
const desc = t.description ? ` (${t.description})` : '';
|
|
629
|
+
choices.push({
|
|
630
|
+
name: `${cmd}${desc}`,
|
|
631
|
+
value: cmd,
|
|
632
|
+
checked: t.checked !== false
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const response = await inquirer.prompt({
|
|
637
|
+
type: 'checkbox',
|
|
638
|
+
name: 'selected',
|
|
639
|
+
message: 'Select default post-config tasks to include in this template:',
|
|
640
|
+
loop: false,
|
|
641
|
+
theme: {
|
|
642
|
+
icon: {
|
|
643
|
+
checked: chalk.green('[x] '),
|
|
644
|
+
unchecked: '[ ] ',
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
choices
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
return response.selected || [];
|
|
651
|
+
}
|