@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,269 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { FolderNode, TemplateConfig, CopyFileEntry, PostConfigTask, PostCopyFile, shouldIgnore, shouldExclude, shouldExcludeFile } from '../config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract folder structure skeleton from a directory
|
|
7
|
+
* Only includes directories, with optional .info.md content
|
|
8
|
+
*/
|
|
9
|
+
export function extractStructure(dirPath: string, rootPath: string, ignorePatterns?: string[]): FolderNode[] {
|
|
10
|
+
const nodes: FolderNode[] = [];
|
|
11
|
+
try {
|
|
12
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
15
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
16
|
+
|
|
17
|
+
// Only include directories in the structure skeleton
|
|
18
|
+
const isDirectory = entry.isDirectory() || (entry.isSymbolicLink() && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory());
|
|
19
|
+
if (!isDirectory) continue;
|
|
20
|
+
|
|
21
|
+
if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
|
|
22
|
+
if (shouldExclude(dirPath, fullPath)) continue;
|
|
23
|
+
|
|
24
|
+
const children = extractStructure(fullPath, rootPath, ignorePatterns);
|
|
25
|
+
let info = '';
|
|
26
|
+
const gitkeepPath = path.join(fullPath, '.gitkeep.md');
|
|
27
|
+
const infoPath = path.join(fullPath, '.info.md');
|
|
28
|
+
if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
|
|
29
|
+
else if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
|
|
30
|
+
nodes.push({ name: entry.name, info, children });
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
// Ignore directory read errors
|
|
34
|
+
}
|
|
35
|
+
return nodes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Scan text files in top-level and 1st-level subdirectories for {{ variable_name }} placeholders.
|
|
40
|
+
*/
|
|
41
|
+
export function findVariablesInFiles(dirPath: string, rootPath: string, ignorePatterns?: string[]): string[] {
|
|
42
|
+
const variables = new Set<string>();
|
|
43
|
+
const regex = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
44
|
+
|
|
45
|
+
const textExtensions = ['.md', '.txt', '.makerc', '.json', '.yaml', '.yml', '.ini', '.conf', '.config', '.sh', '.py', '.js', '.ts', '.html', '.css', '.makefile'];
|
|
46
|
+
|
|
47
|
+
const scan = (currentPath: string, depth: number) => {
|
|
48
|
+
if (depth > 1) return; // Top level (0) and 1st level subfolders (1)
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
54
|
+
const relativePath = path.relative(rootPath, fullPath);
|
|
55
|
+
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
if (shouldIgnore(entry.name, relativePath, ignorePatterns)) continue;
|
|
58
|
+
if (shouldExclude(currentPath, fullPath)) continue;
|
|
59
|
+
scan(fullPath, depth + 1);
|
|
60
|
+
} else if (entry.isFile()) {
|
|
61
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
62
|
+
const isMakefile = entry.name.toLowerCase() === 'makefile';
|
|
63
|
+
|
|
64
|
+
if (textExtensions.includes(ext) || isMakefile || ext === '') {
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
67
|
+
let match;
|
|
68
|
+
regex.lastIndex = 0;
|
|
69
|
+
while ((match = regex.exec(content)) !== null) {
|
|
70
|
+
variables.add(match[1]);
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Skip files that can't be read or aren't text
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// Ignore directory read errors
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
scan(dirPath, 0);
|
|
84
|
+
return Array.from(variables);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a file is executable (by extension or permissions)
|
|
89
|
+
*/
|
|
90
|
+
export function isExecutable(fullPath: string, fileName: string): boolean {
|
|
91
|
+
if (shouldExcludeFile(fileName)) return false;
|
|
92
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
93
|
+
if (['.sh', '.py', '.bash', '.bat', '.cmd'].includes(ext)) return true;
|
|
94
|
+
if (fileName.toLowerCase() === 'makefile') return true;
|
|
95
|
+
try {
|
|
96
|
+
const stat = fs.statSync(fullPath);
|
|
97
|
+
return !!(stat.mode & 0o111);
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse .info.md file for name and description
|
|
105
|
+
*/
|
|
106
|
+
export function parseInfoFile(infoPath: string): { name: string; description: string } {
|
|
107
|
+
let name = '';
|
|
108
|
+
let description = '';
|
|
109
|
+
if (fs.existsSync(infoPath)) {
|
|
110
|
+
const content = fs.readFileSync(infoPath, 'utf-8');
|
|
111
|
+
const lines = content.split('\n');
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.startsWith('# ')) {
|
|
114
|
+
name = line.substring(2).trim();
|
|
115
|
+
} else if (line.trim() !== '' && !description && !line.startsWith('#')) {
|
|
116
|
+
description = line.trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { name, description };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Load JSON template config from .pt-template.json or template.json
|
|
125
|
+
*/
|
|
126
|
+
export function loadJsonTemplateConfig(dirPath: string): Partial<TemplateConfig> & { name?: string } {
|
|
127
|
+
const jsonConfigPaths = [
|
|
128
|
+
path.join(dirPath, '.pt-template.json'),
|
|
129
|
+
path.join(dirPath, 'template.json')
|
|
130
|
+
];
|
|
131
|
+
for (const jPath of jsonConfigPaths) {
|
|
132
|
+
if (fs.existsSync(jPath)) {
|
|
133
|
+
try {
|
|
134
|
+
const content = fs.readFileSync(jPath, 'utf-8');
|
|
135
|
+
return JSON.parse(content);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.warn(`Warning: Failed to parse ${path.basename(jPath)}: ${(e as Error).message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get root-level files and directories (for copy_files selection)
|
|
146
|
+
*/
|
|
147
|
+
export function getRootEntries(dirPath: string, ignorePatterns?: string[]): { files: string[]; dirs: string[] } {
|
|
148
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
149
|
+
.filter(e => !shouldExclude(dirPath, path.join(dirPath, e.name), ignorePatterns))
|
|
150
|
+
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
151
|
+
|
|
152
|
+
const files = entries.filter(e => e.isFile()).map(e => e.name);
|
|
153
|
+
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
154
|
+
return { files, dirs };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Detect executable files at root level
|
|
159
|
+
*/
|
|
160
|
+
export function detectRootExecutables(dirPath: string, ignorePatterns?: string[]): string[] {
|
|
161
|
+
const { files } = getRootEntries(dirPath, ignorePatterns);
|
|
162
|
+
return files.filter(file => isExecutable(path.join(dirPath, file), file));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse post_config.sh or post_config.bat for commands
|
|
167
|
+
*/
|
|
168
|
+
export function parsePostConfigScript(shPath: string, batPath: string): PostConfigTask[] {
|
|
169
|
+
const tasks: PostConfigTask[] = [];
|
|
170
|
+
|
|
171
|
+
if (fs.existsSync(shPath)) {
|
|
172
|
+
const lines = fs.readFileSync(shPath, 'utf-8').split('\n');
|
|
173
|
+
let currentDesc = '';
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
if (line.startsWith('echo "Running: ')) {
|
|
176
|
+
currentDesc = line.substring(15, line.length - 1).replace(/"$/, '');
|
|
177
|
+
} else if (line.trim() && !line.startsWith('#') && !line.startsWith('echo ')) {
|
|
178
|
+
tasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
179
|
+
currentDesc = '';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} else if (fs.existsSync(batPath)) {
|
|
183
|
+
const lines = fs.readFileSync(batPath, 'utf-8').split('\n');
|
|
184
|
+
let currentDesc = '';
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
if (line.startsWith('echo Running: ')) {
|
|
187
|
+
currentDesc = line.substring(14).trim();
|
|
188
|
+
} else if (line.trim() && !line.startsWith('::') && !line.startsWith('@echo') && !line.startsWith('echo ')) {
|
|
189
|
+
tasks.push({ command: line.trim(), description: currentDesc || line.trim() });
|
|
190
|
+
currentDesc = '';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return tasks;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build copy_files array from selected files and folders
|
|
199
|
+
*/
|
|
200
|
+
export function buildCopyFiles(
|
|
201
|
+
selectedFiles: string[],
|
|
202
|
+
selectedFolders: string[],
|
|
203
|
+
existingCopyFiles: CopyFileEntry[] = []
|
|
204
|
+
): CopyFileEntry[] {
|
|
205
|
+
const copyFiles: CopyFileEntry[] = [];
|
|
206
|
+
const existingSrcs = new Set(existingCopyFiles.map(e => e.src));
|
|
207
|
+
|
|
208
|
+
// Start with existing entries (preserves substitute_variables, chmod settings)
|
|
209
|
+
copyFiles.push(...existingCopyFiles);
|
|
210
|
+
|
|
211
|
+
// Add new files
|
|
212
|
+
for (const f of selectedFiles) {
|
|
213
|
+
if (existingSrcs.has(f)) continue;
|
|
214
|
+
copyFiles.push({ src: f, dest: f, substitute_variables: true });
|
|
215
|
+
}
|
|
216
|
+
// Add new directories
|
|
217
|
+
for (const d of selectedFolders) {
|
|
218
|
+
if (existingSrcs.has(d)) continue;
|
|
219
|
+
copyFiles.push({ src: d, dest: d, substitute_variables: true });
|
|
220
|
+
}
|
|
221
|
+
return copyFiles;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Merge post_config tasks from JSON config and/or detected scripts
|
|
226
|
+
*/
|
|
227
|
+
export function mergePostConfigTasks(
|
|
228
|
+
existingTasks: PostConfigTask[],
|
|
229
|
+
jsonTasks: PostConfigTask[] | undefined,
|
|
230
|
+
detectedTasks: PostConfigTask[]
|
|
231
|
+
): PostConfigTask[] {
|
|
232
|
+
let tasks = [...existingTasks];
|
|
233
|
+
|
|
234
|
+
// JSON config takes precedence (full replacement)
|
|
235
|
+
if (jsonTasks && jsonTasks.length > 0) {
|
|
236
|
+
tasks = [...jsonTasks];
|
|
237
|
+
} else if (detectedTasks.length > 0) {
|
|
238
|
+
// Add detected tasks that don't already exist
|
|
239
|
+
for (const dt of detectedTasks) {
|
|
240
|
+
const exists = tasks.some(t => t.command === dt.command || (t.script && t.script === dt.script));
|
|
241
|
+
if (!exists) tasks.push(dt);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return tasks;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Merge post_copy files from JSON config and/or detected executables
|
|
249
|
+
*/
|
|
250
|
+
export function mergePostCopyFiles(
|
|
251
|
+
existingFiles: PostCopyFile[],
|
|
252
|
+
jsonFiles: PostCopyFile[] | undefined,
|
|
253
|
+
detectedFiles: string[]
|
|
254
|
+
): PostCopyFile[] {
|
|
255
|
+
let files = [...existingFiles];
|
|
256
|
+
|
|
257
|
+
if (jsonFiles && jsonFiles.length > 0) {
|
|
258
|
+
for (const jf of jsonFiles) {
|
|
259
|
+
if (!files.some(ef => ef.src === jf.src)) files.push(jf);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const df of detectedFiles) {
|
|
264
|
+
if (!files.some(ef => ef.src === df)) {
|
|
265
|
+
files.push({ src: df, dest: df });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return files;
|
|
269
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { TemplateVariable, FolderNode, CopyFileEntry, PostConfigTask } from '../config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prompt for template name with auto-detection and confirmation
|
|
7
|
+
*/
|
|
8
|
+
export async function promptTemplateName(
|
|
9
|
+
currentName: string,
|
|
10
|
+
existingNames: string[],
|
|
11
|
+
sourceName: string | null,
|
|
12
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
13
|
+
): Promise<string> {
|
|
14
|
+
if (options.yes || options.json) return currentName;
|
|
15
|
+
|
|
16
|
+
// If name came from source (.info.md or JSON), confirm it
|
|
17
|
+
if (sourceName && sourceName === currentName) {
|
|
18
|
+
const { confirmName } = await inquirer.prompt({
|
|
19
|
+
type: 'confirm',
|
|
20
|
+
name: 'confirmName',
|
|
21
|
+
message: `Use "${currentName}" as the template name?`,
|
|
22
|
+
default: true
|
|
23
|
+
});
|
|
24
|
+
if (!confirmName) {
|
|
25
|
+
const { newName } = await inquirer.prompt({
|
|
26
|
+
type: 'input',
|
|
27
|
+
name: 'newName',
|
|
28
|
+
message: 'Name this template:',
|
|
29
|
+
default: currentName
|
|
30
|
+
});
|
|
31
|
+
return newName;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check for existing template name
|
|
36
|
+
if (existingNames.includes(currentName)) {
|
|
37
|
+
console.warn(chalk.yellow(`ā Warning: "${currentName}" already exists. Using this name will overwrite.`));
|
|
38
|
+
}
|
|
39
|
+
return currentName;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prompt for template description
|
|
44
|
+
*/
|
|
45
|
+
export async function promptDescription(
|
|
46
|
+
currentDesc: string,
|
|
47
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
if (options.yes || options.json) return currentDesc;
|
|
50
|
+
|
|
51
|
+
const { description } = await inquirer.prompt({
|
|
52
|
+
type: 'input',
|
|
53
|
+
name: 'description',
|
|
54
|
+
message: 'Template description:',
|
|
55
|
+
default: currentDesc
|
|
56
|
+
});
|
|
57
|
+
return description;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Prompt for template root directory
|
|
62
|
+
*/
|
|
63
|
+
export async function promptTemplateRoot(
|
|
64
|
+
currentRoot: string,
|
|
65
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
if (options.yes || options.json) return currentRoot;
|
|
68
|
+
|
|
69
|
+
const { templateRoot } = await inquirer.prompt({
|
|
70
|
+
type: 'input',
|
|
71
|
+
name: 'templateRoot',
|
|
72
|
+
message: 'Template root folder:',
|
|
73
|
+
default: currentRoot
|
|
74
|
+
});
|
|
75
|
+
return templateRoot;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Prompt for new variables (checkbox selection)
|
|
80
|
+
*/
|
|
81
|
+
export async function promptNewVariables(
|
|
82
|
+
newVars: TemplateVariable[],
|
|
83
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
84
|
+
): Promise<TemplateVariable[]> {
|
|
85
|
+
if (newVars.length === 0) return [];
|
|
86
|
+
|
|
87
|
+
if (options.yes || options.json) return newVars;
|
|
88
|
+
|
|
89
|
+
console.log(chalk.cyan(`\nš New Variables:`));
|
|
90
|
+
console.log(chalk.green(` + ${newVars.length} new variable(s): ${newVars.map(v => v.name).join(', ')}`));
|
|
91
|
+
|
|
92
|
+
const { selectedVars } = await inquirer.prompt({
|
|
93
|
+
type: 'checkbox',
|
|
94
|
+
name: 'selectedVars',
|
|
95
|
+
message: 'Select variables to include (space to toggle):',
|
|
96
|
+
loop: false,
|
|
97
|
+
theme: {
|
|
98
|
+
icon: {
|
|
99
|
+
checked: chalk.green('[x] '),
|
|
100
|
+
unchecked: '[ ] ',
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
choices: newVars.map(v => ({ name: v.name, checked: true }))
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return newVars.filter(v => selectedVars.includes(v.name));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Prompt for global/default variables to include
|
|
111
|
+
*/
|
|
112
|
+
export async function promptGlobalVariables(
|
|
113
|
+
globalVars: TemplateVariable[],
|
|
114
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
115
|
+
): Promise<TemplateVariable[]> {
|
|
116
|
+
if (globalVars.length === 0) return [];
|
|
117
|
+
|
|
118
|
+
if (options.yes || options.json) return globalVars;
|
|
119
|
+
|
|
120
|
+
const { selectedGlobals } = await inquirer.prompt({
|
|
121
|
+
type: 'checkbox',
|
|
122
|
+
name: 'selectedGlobals',
|
|
123
|
+
message: 'Select default/global variables to include (space to toggle):',
|
|
124
|
+
loop: false,
|
|
125
|
+
theme: {
|
|
126
|
+
icon: {
|
|
127
|
+
checked: chalk.green('[x] '),
|
|
128
|
+
unchecked: '[ ] ',
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
choices: globalVars.map(v => ({ name: v.name, checked: true }))
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return globalVars.filter(v => selectedGlobals.includes(v.name));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Prompt for additional custom variables
|
|
139
|
+
*/
|
|
140
|
+
export async function promptAdditionalVariables(
|
|
141
|
+
existingVars: TemplateVariable[],
|
|
142
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
143
|
+
): Promise<TemplateVariable[]> {
|
|
144
|
+
if (options.yes || options.json) return [];
|
|
145
|
+
|
|
146
|
+
const message = existingVars.length > 0
|
|
147
|
+
? `Existing variables: ${existingVars.map(v => v.name).join(', ')}. Define more?`
|
|
148
|
+
: 'Define template variables (e.g., client_name, project_type)?';
|
|
149
|
+
|
|
150
|
+
const { hasMore } = await inquirer.prompt({
|
|
151
|
+
type: 'confirm',
|
|
152
|
+
name: 'hasMore',
|
|
153
|
+
message,
|
|
154
|
+
default: false
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!hasMore) return [];
|
|
158
|
+
|
|
159
|
+
const { variableDefs } = await inquirer.prompt({
|
|
160
|
+
type: 'input',
|
|
161
|
+
name: 'variableDefs',
|
|
162
|
+
message: 'Define additional variables as comma-separated names:'
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!variableDefs) return [];
|
|
166
|
+
|
|
167
|
+
const additionalVars = variableDefs.split(',').map((v: string) => v.trim()).filter(Boolean);
|
|
168
|
+
return additionalVars
|
|
169
|
+
.filter((v: string) => !existingVars.some((existing: TemplateVariable) => existing.name === v))
|
|
170
|
+
.map((v: string) => ({ name: v, prompt: `Enter ${v}:`, required: true }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Prompt for new folders (checkbox selection)
|
|
175
|
+
*/
|
|
176
|
+
export async function promptNewFolders(
|
|
177
|
+
newFolders: FolderNode[],
|
|
178
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
179
|
+
): Promise<FolderNode[]> {
|
|
180
|
+
if (newFolders.length === 0) return [];
|
|
181
|
+
|
|
182
|
+
if (options.yes || options.json) return newFolders;
|
|
183
|
+
|
|
184
|
+
console.log(chalk.cyan(`\nš New Folders:`));
|
|
185
|
+
console.log(chalk.green(` + ${newFolders.length} new folder(s): ${newFolders.map(f => f.name).join(', ')}`));
|
|
186
|
+
|
|
187
|
+
const { selectedFolders } = await inquirer.prompt({
|
|
188
|
+
type: 'checkbox',
|
|
189
|
+
name: 'selectedFolders',
|
|
190
|
+
message: 'Select folders to include in template structure (space to toggle):',
|
|
191
|
+
loop: false,
|
|
192
|
+
theme: {
|
|
193
|
+
icon: {
|
|
194
|
+
checked: chalk.green('[x] '),
|
|
195
|
+
unchecked: '[ ] ',
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
choices: newFolders.map(f => ({ name: f.name, checked: true }))
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return newFolders.filter(f => selectedFolders.includes(f.name));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Prompt for root files (checkbox selection)
|
|
206
|
+
*/
|
|
207
|
+
export async function promptRootFiles(
|
|
208
|
+
files: string[],
|
|
209
|
+
defaults: string[] = ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'],
|
|
210
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
211
|
+
): Promise<string[]> {
|
|
212
|
+
if (files.length === 0) return [];
|
|
213
|
+
|
|
214
|
+
if (options.yes || options.json) {
|
|
215
|
+
return files.filter(f => defaults.some(d => f.toLowerCase() === d.toLowerCase()));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { selectedFiles } = await inquirer.prompt({
|
|
219
|
+
type: 'checkbox',
|
|
220
|
+
name: 'selectedFiles',
|
|
221
|
+
message: 'Select root files to include as boilerplate:',
|
|
222
|
+
loop: false,
|
|
223
|
+
theme: {
|
|
224
|
+
icon: {
|
|
225
|
+
checked: chalk.green('[x] '),
|
|
226
|
+
unchecked: '[ ] ',
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
choices: files.map(f => ({
|
|
230
|
+
name: f,
|
|
231
|
+
checked: defaults.some(d => f.toLowerCase() === d.toLowerCase())
|
|
232
|
+
}))
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return selectedFiles;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Prompt for folders in structure (checkbox selection)
|
|
240
|
+
*/
|
|
241
|
+
export async function promptStructureFolders(
|
|
242
|
+
dirs: string[],
|
|
243
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
244
|
+
): Promise<string[]> {
|
|
245
|
+
if (dirs.length === 0) return [];
|
|
246
|
+
|
|
247
|
+
if (options.yes || options.json) return dirs;
|
|
248
|
+
|
|
249
|
+
const { selectedStructure } = await inquirer.prompt({
|
|
250
|
+
type: 'checkbox',
|
|
251
|
+
name: 'selectedStructure',
|
|
252
|
+
message: 'Select folders to include in the template structure (skeleton):',
|
|
253
|
+
loop: false,
|
|
254
|
+
theme: {
|
|
255
|
+
icon: {
|
|
256
|
+
checked: chalk.green('[x] '),
|
|
257
|
+
unchecked: '[ ] ',
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
choices: dirs.map(d => ({ name: d, checked: true }))
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return selectedStructure;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Prompt for folders to copy recursively (checkbox selection)
|
|
268
|
+
*/
|
|
269
|
+
export async function promptCopyFolders(
|
|
270
|
+
structureFolders: string[],
|
|
271
|
+
defaults: string[] = ['APP', 'scripts', 'bin'],
|
|
272
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
273
|
+
): Promise<string[]> {
|
|
274
|
+
if (structureFolders.length === 0) return [];
|
|
275
|
+
|
|
276
|
+
if (options.yes || options.json) {
|
|
277
|
+
return structureFolders.filter(d => defaults.includes(d));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const { selectedFolders } = await inquirer.prompt({
|
|
281
|
+
type: 'checkbox',
|
|
282
|
+
name: 'selectedFolders',
|
|
283
|
+
message: 'Select folders to copy RECURSIVELY as boilerplate (with contents):',
|
|
284
|
+
loop: false,
|
|
285
|
+
theme: {
|
|
286
|
+
icon: {
|
|
287
|
+
checked: chalk.green('[x] '),
|
|
288
|
+
unchecked: '[ ] ',
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
choices: structureFolders.map(d => ({
|
|
292
|
+
name: d,
|
|
293
|
+
checked: defaults.includes(d)
|
|
294
|
+
}))
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return selectedFolders;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Prompt for post-config tasks
|
|
302
|
+
*/
|
|
303
|
+
export async function promptPostConfigTasks(
|
|
304
|
+
tasks: PostConfigTask[],
|
|
305
|
+
options: { yes?: boolean; json?: boolean; dryRun?: boolean } = {}
|
|
306
|
+
): Promise<string[]> {
|
|
307
|
+
if (tasks.length === 0) return [];
|
|
308
|
+
|
|
309
|
+
if (options.dryRun) return tasks.map(t => t.command || `./${t.script}` || '');
|
|
310
|
+
if (options.yes || options.json) return tasks.map(t => t.command || `./${t.script}` || '');
|
|
311
|
+
|
|
312
|
+
const choices = tasks.map(t => ({
|
|
313
|
+
name: `${t.command || `./${t.script}` || '(no command)'}${t.description ? ` (${t.description})` : ''}`,
|
|
314
|
+
value: t.command || `./${t.script}` || '',
|
|
315
|
+
checked: true
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
const { selected } = await inquirer.prompt({
|
|
319
|
+
type: 'checkbox',
|
|
320
|
+
name: 'selected',
|
|
321
|
+
message: 'Select post-config tasks to run:',
|
|
322
|
+
loop: false,
|
|
323
|
+
theme: {
|
|
324
|
+
icon: {
|
|
325
|
+
checked: chalk.green('[x] '),
|
|
326
|
+
unchecked: '[ ] ',
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
choices
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return selected || [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Prompt to add detected executables to post_copy
|
|
337
|
+
*/
|
|
338
|
+
export async function promptAddPostCopy(
|
|
339
|
+
executables: string[],
|
|
340
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
341
|
+
): Promise<boolean> {
|
|
342
|
+
if (executables.length === 0) return false;
|
|
343
|
+
if (options.yes || options.json) return true;
|
|
344
|
+
|
|
345
|
+
const { addPostCopy } = await inquirer.prompt({
|
|
346
|
+
type: 'confirm',
|
|
347
|
+
name: 'addPostCopy',
|
|
348
|
+
message: 'Add these to post_copy (auto-chmod)?',
|
|
349
|
+
default: true
|
|
350
|
+
});
|
|
351
|
+
return addPostCopy;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Print new files detection message
|
|
356
|
+
*/
|
|
357
|
+
export function printNewFiles(fileCount: number, fileNames: string[]): void {
|
|
358
|
+
if (fileCount > 0) {
|
|
359
|
+
console.log(chalk.cyan(`\nš New Files:`));
|
|
360
|
+
console.log(chalk.green(` + ${fileCount} new file(s): ${fileNames.join(', ')}`));
|
|
361
|
+
} else {
|
|
362
|
+
console.log(chalk.cyan("No new files detected"));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Print no new folders message
|
|
368
|
+
*/
|
|
369
|
+
export function printNoNewFolders(): void {
|
|
370
|
+
console.log(chalk.cyan("No new folders detected"));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Print no new variables message
|
|
375
|
+
*/
|
|
376
|
+
export function printNoNewVariables(): void {
|
|
377
|
+
console.log(chalk.cyan("No new variables detected"));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Prompt for new files (checkbox selection)
|
|
382
|
+
*/
|
|
383
|
+
export async function promptNewFiles(
|
|
384
|
+
newFiles: string[],
|
|
385
|
+
options: { yes?: boolean; json?: boolean } = {}
|
|
386
|
+
): Promise<string[]> {
|
|
387
|
+
if (newFiles.length === 0) return [];
|
|
388
|
+
|
|
389
|
+
if (options.yes || options.json) return newFiles;
|
|
390
|
+
|
|
391
|
+
const { selectedFileChoices } = await inquirer.prompt({
|
|
392
|
+
type: 'checkbox',
|
|
393
|
+
name: 'selectedFileChoices',
|
|
394
|
+
message: 'Select files to include (space to toggle):',
|
|
395
|
+
loop: false,
|
|
396
|
+
theme: {
|
|
397
|
+
icon: {
|
|
398
|
+
checked: chalk.green('[x] '),
|
|
399
|
+
unchecked: '[ ] ',
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
choices: newFiles.map(f => ({ name: f, checked: true }))
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return newFiles.filter(f => selectedFileChoices.includes(f));
|
|
406
|
+
}
|