@garyr/pt-cli 1.1.1 → 1.3.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/CHANGELOG.md +47 -0
- package/README.md +26 -27
- package/dist/commands/completionCommand.js +49 -12
- package/dist/commands/initCommand.js +476 -181
- package/dist/index.js +22 -4
- package/dist/substitute.js +45 -19
- package/doc/configuration.md +7 -5
- package/doc/usage.md +22 -5
- package/package.json +1 -1
- package/skills/agency-pt-operator/SKILL.md +5 -4
- package/src/commands/completionCommand.ts +49 -12
- package/src/commands/initCommand.ts +495 -194
- package/src/index.ts +20 -4
- package/src/substitute.ts +48 -21
- package/tests/modularity.test.ts +605 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
|
-
import { loadConfig, FolderNode, sanitizePath, TemplateConfig } from '../config.js';
|
|
4
|
+
import { loadConfig, FolderNode, sanitizePath, TemplateConfig, TemplateVariable, PostConfigTask, PtConfig } from '../config.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
|
-
import { processCopyFiles } from '../substitute.js';
|
|
6
|
+
import { processCopyFiles, substituteVariables } from '../substitute.js';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
8
|
|
|
9
9
|
export interface InitOptions {
|
|
@@ -12,136 +12,304 @@ export interface InitOptions {
|
|
|
12
12
|
yes?: boolean;
|
|
13
13
|
vars?: string;
|
|
14
14
|
file?: string;
|
|
15
|
+
collision?: 'overwrite' | 'newest';
|
|
16
|
+
json?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface LoadedTemplate {
|
|
20
|
+
name: string;
|
|
21
|
+
template: TemplateConfig;
|
|
22
|
+
sourceFile?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively merges two arrays of FolderNodes, deduplicating matching folder names.
|
|
27
|
+
* Sub-children are recursively merged, and later nodes take precedence for info/is_file.
|
|
28
|
+
*/
|
|
29
|
+
export function mergeFolderNodes(nodesA: FolderNode[], nodesB: FolderNode[]): FolderNode[] {
|
|
30
|
+
const map = new Map<string, FolderNode>();
|
|
31
|
+
|
|
32
|
+
function cloneNode(node: FolderNode): FolderNode {
|
|
33
|
+
return {
|
|
34
|
+
name: node.name,
|
|
35
|
+
info: node.info,
|
|
36
|
+
is_file: node.is_file,
|
|
37
|
+
children: node.children ? node.children.map(cloneNode) : undefined
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const node of nodesA) {
|
|
42
|
+
map.set(node.name, cloneNode(node));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const node of nodesB) {
|
|
46
|
+
if (map.has(node.name)) {
|
|
47
|
+
const existing = map.get(node.name)!;
|
|
48
|
+
if (node.info) {
|
|
49
|
+
existing.info = node.info;
|
|
50
|
+
}
|
|
51
|
+
if (node.is_file !== undefined) {
|
|
52
|
+
existing.is_file = node.is_file;
|
|
53
|
+
}
|
|
54
|
+
if (node.children && node.children.length > 0) {
|
|
55
|
+
existing.children = mergeFolderNodes(existing.children || [], node.children);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
map.set(node.name, cloneNode(node));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Array.from(map.values());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Merges variables across multiple templates.
|
|
67
|
+
* Variables with the same name are deduplicated; later templates override default values.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeVariables(templates: LoadedTemplate[]): TemplateVariable[] {
|
|
70
|
+
const varMap = new Map<string, TemplateVariable>();
|
|
71
|
+
for (const { template } of templates) {
|
|
72
|
+
if (!template.variables) continue;
|
|
73
|
+
for (const v of template.variables) {
|
|
74
|
+
if (varMap.has(v.name)) {
|
|
75
|
+
const existing = varMap.get(v.name)!;
|
|
76
|
+
varMap.set(v.name, {
|
|
77
|
+
name: v.name,
|
|
78
|
+
prompt: v.prompt || existing.prompt,
|
|
79
|
+
default: v.default !== undefined ? v.default : existing.default,
|
|
80
|
+
required: v.required !== undefined ? v.required : existing.required
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
varMap.set(v.name, { ...v });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return Array.from(varMap.values());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Checks if a given destination path corresponds to a root-level readme.md file.
|
|
92
|
+
*/
|
|
93
|
+
export function isRootReadme(filePath: string): boolean {
|
|
94
|
+
const norm = sanitizePath(filePath).replace(/\\/g, '/');
|
|
95
|
+
const parts = norm.split('/').filter(Boolean);
|
|
96
|
+
return parts.length === 1 && /^readme\.md$/i.test(parts[0]);
|
|
15
97
|
}
|
|
16
98
|
|
|
17
99
|
/**
|
|
18
100
|
* Scan parent directories for .env files and parse their variables.
|
|
19
|
-
* Returns a map of variable names to their values, supporting:
|
|
20
|
-
* - KEY=VALUE format
|
|
21
|
-
* - KEY="VALUE with spaces" format
|
|
22
|
-
* - KEY='VALUE with spaces' format
|
|
23
|
-
* - Comments (lines starting with #)
|
|
24
|
-
* - Empty lines
|
|
25
101
|
*/
|
|
26
102
|
function scanEnvForVariables(targetPath: string): Record<string, string> {
|
|
27
103
|
const envVars: Record<string, string> = {};
|
|
28
104
|
let currentDir = path.resolve(targetPath);
|
|
29
|
-
|
|
30
|
-
// Scan up to 5 parent directories for .env files
|
|
31
105
|
const maxDepth = 5;
|
|
32
|
-
|
|
106
|
+
|
|
33
107
|
for (let depth = 0; depth < maxDepth; depth++) {
|
|
34
108
|
const envPath = path.join(currentDir, '.env');
|
|
35
|
-
|
|
109
|
+
|
|
36
110
|
if (fs.existsSync(envPath)) {
|
|
37
111
|
try {
|
|
38
112
|
const content = fs.readFileSync(envPath, 'utf-8');
|
|
39
113
|
const lines = content.split('\n');
|
|
40
|
-
|
|
114
|
+
|
|
41
115
|
for (const line of lines) {
|
|
42
116
|
const trimmed = line.trim();
|
|
43
|
-
|
|
44
|
-
// Skip empty lines and comments
|
|
45
117
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
46
118
|
continue;
|
|
47
119
|
}
|
|
48
|
-
|
|
49
|
-
// Match KEY=VALUE patterns
|
|
120
|
+
|
|
50
121
|
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
51
122
|
if (match) {
|
|
52
123
|
const key = match[1];
|
|
53
124
|
let value = match[2];
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
125
|
+
|
|
126
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
57
127
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
58
128
|
value = value.slice(1, -1);
|
|
59
129
|
}
|
|
60
|
-
|
|
130
|
+
|
|
61
131
|
envVars[key] = value;
|
|
62
132
|
}
|
|
63
133
|
}
|
|
64
134
|
} catch (err) {
|
|
65
|
-
// Silently skip unreadable .env files
|
|
66
135
|
continue;
|
|
67
136
|
}
|
|
68
137
|
}
|
|
69
|
-
|
|
70
|
-
// Move to parent directory
|
|
138
|
+
|
|
71
139
|
const parentDir = path.dirname(currentDir);
|
|
72
140
|
if (parentDir === currentDir) {
|
|
73
|
-
// Reached filesystem root
|
|
74
141
|
break;
|
|
75
142
|
}
|
|
76
143
|
currentDir = parentDir;
|
|
77
144
|
}
|
|
78
|
-
|
|
145
|
+
|
|
79
146
|
return envVars;
|
|
80
147
|
}
|
|
81
148
|
|
|
82
|
-
export async function init(
|
|
149
|
+
export async function init(
|
|
150
|
+
targetOrArgs?: string | string[] | undefined,
|
|
151
|
+
destPathOrOptions?: string | InitOptions,
|
|
152
|
+
optionsOrUndefined?: InitOptions
|
|
153
|
+
) {
|
|
83
154
|
const config = loadConfig();
|
|
84
155
|
|
|
85
|
-
let
|
|
86
|
-
let dest: string | undefined
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
156
|
+
let rawTemplates: string[] = [];
|
|
157
|
+
let dest: string | undefined;
|
|
158
|
+
let options: InitOptions = {};
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(targetOrArgs)) {
|
|
161
|
+
options = (destPathOrOptions as InitOptions) || {};
|
|
162
|
+
if (targetOrArgs.length === 0) {
|
|
163
|
+
rawTemplates = [];
|
|
164
|
+
dest = undefined;
|
|
165
|
+
} else if (targetOrArgs.length === 1) {
|
|
166
|
+
if (options.file) {
|
|
167
|
+
rawTemplates = [options.file];
|
|
168
|
+
dest = targetOrArgs[0];
|
|
169
|
+
} else {
|
|
170
|
+
rawTemplates = [targetOrArgs[0]];
|
|
171
|
+
dest = undefined;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
if (options.file) {
|
|
175
|
+
rawTemplates = [options.file, ...targetOrArgs.slice(0, -1)];
|
|
176
|
+
} else {
|
|
177
|
+
rawTemplates = targetOrArgs.slice(0, -1);
|
|
178
|
+
}
|
|
179
|
+
dest = targetOrArgs[targetOrArgs.length - 1];
|
|
180
|
+
}
|
|
181
|
+
} else if (typeof targetOrArgs === 'string') {
|
|
182
|
+
if (typeof destPathOrOptions === 'string') {
|
|
183
|
+
dest = destPathOrOptions;
|
|
184
|
+
options = optionsOrUndefined || {};
|
|
185
|
+
} else if (destPathOrOptions !== undefined) {
|
|
186
|
+
dest = undefined;
|
|
187
|
+
options = (destPathOrOptions as InitOptions) || {};
|
|
188
|
+
} else {
|
|
189
|
+
dest = undefined;
|
|
190
|
+
options = optionsOrUndefined || {};
|
|
94
191
|
}
|
|
192
|
+
if (options.file && !dest) {
|
|
193
|
+
dest = targetOrArgs;
|
|
194
|
+
rawTemplates = [options.file];
|
|
195
|
+
} else {
|
|
196
|
+
rawTemplates = options.file ? [options.file, targetOrArgs] : [targetOrArgs];
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// targetOrArgs is undefined
|
|
200
|
+
if (typeof destPathOrOptions === 'string') {
|
|
201
|
+
dest = destPathOrOptions;
|
|
202
|
+
options = optionsOrUndefined || {};
|
|
203
|
+
} else if (destPathOrOptions !== undefined) {
|
|
204
|
+
dest = undefined;
|
|
205
|
+
options = (destPathOrOptions as InitOptions) || {};
|
|
206
|
+
} else {
|
|
207
|
+
dest = undefined;
|
|
208
|
+
options = optionsOrUndefined || {};
|
|
209
|
+
}
|
|
210
|
+
if (options.file) {
|
|
211
|
+
rawTemplates = [options.file];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
95
214
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
215
|
+
// Interactive selection if no templates specified
|
|
216
|
+
if (rawTemplates.length === 0) {
|
|
217
|
+
const names = Object.keys(config.templates);
|
|
218
|
+
if (names.length === 0) {
|
|
219
|
+
const msg = "No templates found. Run 'pt learn <path>' first.";
|
|
220
|
+
if (options.json) {
|
|
221
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
222
|
+
} else {
|
|
223
|
+
console.log(chalk.red(msg));
|
|
224
|
+
}
|
|
101
225
|
process.exit(1);
|
|
102
226
|
}
|
|
103
227
|
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const names = Object.keys(config.templates);
|
|
111
|
-
if (names.length === 0) {
|
|
112
|
-
console.log(chalk.red("No templates found. Run 'pt learn <path>' first."));
|
|
113
|
-
return;
|
|
228
|
+
if (options.yes) {
|
|
229
|
+
const msg = "No project type specified and running in non-interactive mode.";
|
|
230
|
+
if (options.json) {
|
|
231
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
232
|
+
} else {
|
|
233
|
+
console.error(chalk.red(msg));
|
|
114
234
|
}
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { selected } = await inquirer.prompt({
|
|
239
|
+
type: 'checkbox',
|
|
240
|
+
name: 'selected',
|
|
241
|
+
message: 'Select Project Type(s):',
|
|
242
|
+
loop: false,
|
|
243
|
+
validate: (answer) => (answer.length < 1 ? 'You must choose at least one template.' : true),
|
|
244
|
+
theme: {
|
|
245
|
+
icon: {
|
|
246
|
+
checked: chalk.green('[x] '),
|
|
247
|
+
unchecked: '[ ] '
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
choices: names.map(n => ({ name: n, value: n }))
|
|
251
|
+
});
|
|
252
|
+
rawTemplates = selected;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Load each template configuration
|
|
256
|
+
const loadedTemplates: LoadedTemplate[] = [];
|
|
257
|
+
for (const item of rawTemplates) {
|
|
258
|
+
// Check if item is a local json file path or exists on disk
|
|
259
|
+
if (item.endsWith('.json') || fs.existsSync(item)) {
|
|
260
|
+
try {
|
|
261
|
+
const resolvedPath = path.resolve(item);
|
|
262
|
+
const fileContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
263
|
+
const parsed = JSON.parse(fileContent);
|
|
264
|
+
const name = parsed.name || path.basename(item, path.extname(item));
|
|
265
|
+
|
|
266
|
+
if (parsed.templateRoot && !path.isAbsolute(parsed.templateRoot)) {
|
|
267
|
+
parsed.templateRoot = path.resolve(path.dirname(resolvedPath), parsed.templateRoot);
|
|
268
|
+
} else if (!parsed.templateRoot) {
|
|
269
|
+
parsed.templateRoot = path.dirname(resolvedPath);
|
|
270
|
+
}
|
|
115
271
|
|
|
116
|
-
|
|
117
|
-
|
|
272
|
+
loadedTemplates.push({
|
|
273
|
+
name,
|
|
274
|
+
template: parsed,
|
|
275
|
+
sourceFile: resolvedPath
|
|
276
|
+
});
|
|
277
|
+
} catch (e: any) {
|
|
278
|
+
const msg = `Failed to read/parse template file "${item}": ${e.message}`;
|
|
279
|
+
if (options.json) {
|
|
280
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
281
|
+
} else {
|
|
282
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
283
|
+
}
|
|
118
284
|
process.exit(1);
|
|
119
285
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
286
|
+
} else {
|
|
287
|
+
const template = config.templates[item];
|
|
288
|
+
if (!template) {
|
|
289
|
+
const msg = `Template "${item}" not found.`;
|
|
290
|
+
if (options.json) {
|
|
291
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
292
|
+
} else {
|
|
293
|
+
console.error(chalk.red(msg));
|
|
294
|
+
}
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
loadedTemplates.push({
|
|
298
|
+
name: item,
|
|
299
|
+
template: JSON.parse(JSON.stringify(template)) // Clone to prevent mutating config
|
|
131
300
|
});
|
|
132
|
-
typeName = selected;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
template = config.templates[typeName!];
|
|
136
|
-
if (!template) {
|
|
137
|
-
console.error(chalk.red(`Template "${typeName}" not found.`));
|
|
138
|
-
process.exit(1);
|
|
139
301
|
}
|
|
140
302
|
}
|
|
141
303
|
|
|
304
|
+
// Prompt for destination if not provided
|
|
142
305
|
if (!dest) {
|
|
143
306
|
if (options.yes) {
|
|
144
|
-
|
|
307
|
+
const msg = "No destination path specified and running in non-interactive mode.";
|
|
308
|
+
if (options.json) {
|
|
309
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
310
|
+
} else {
|
|
311
|
+
console.error(chalk.red(msg));
|
|
312
|
+
}
|
|
145
313
|
process.exit(1);
|
|
146
314
|
}
|
|
147
315
|
const { name } = await inquirer.prompt({
|
|
@@ -155,23 +323,35 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
155
323
|
const resolvedDest = path.resolve(dest!);
|
|
156
324
|
|
|
157
325
|
if (fs.existsSync(resolvedDest) && !options.dryRun) {
|
|
158
|
-
|
|
326
|
+
const msg = `Destination "${resolvedDest}" already exists.`;
|
|
327
|
+
if (options.json) {
|
|
328
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
329
|
+
} else {
|
|
330
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
331
|
+
}
|
|
159
332
|
process.exit(1);
|
|
160
333
|
}
|
|
161
334
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
335
|
+
const templateNames = loadedTemplates.map(l => l.name);
|
|
336
|
+
const compositeDescription = loadedTemplates.length === 1
|
|
337
|
+
? (loadedTemplates[0].template.description || '')
|
|
338
|
+
: loadedTemplates.map(l => `${l.name}: ${l.template.description || ''}`).join('; ');
|
|
339
|
+
|
|
340
|
+
if (!options.json) {
|
|
341
|
+
if (options.dryRun) {
|
|
342
|
+
console.log(chalk.yellow(`\n[DRY RUN] Initializing project "${compositeDescription}" at: ${resolvedDest}`));
|
|
343
|
+
} else {
|
|
344
|
+
console.log(chalk.cyan(`\nInitializing project "${compositeDescription}" at: ${resolvedDest}`));
|
|
345
|
+
}
|
|
166
346
|
}
|
|
167
347
|
|
|
168
|
-
//
|
|
348
|
+
// Merge Variables
|
|
349
|
+
const mergedVarsDef = mergeVariables(loadedTemplates);
|
|
169
350
|
let variables: Record<string, string> = {};
|
|
170
|
-
|
|
351
|
+
|
|
352
|
+
if (mergedVarsDef.length > 0) {
|
|
171
353
|
// Scan parent directories for .env files and pre-fill variables
|
|
172
354
|
const envVars = scanEnvForVariables(resolvedDest);
|
|
173
|
-
|
|
174
|
-
// Merge .env variables into variables (with lower priority than --vars)
|
|
175
355
|
if (Object.keys(envVars).length > 0) {
|
|
176
356
|
for (const [key, value] of Object.entries(envVars)) {
|
|
177
357
|
if (!variables[key]) {
|
|
@@ -179,9 +359,8 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
179
359
|
}
|
|
180
360
|
}
|
|
181
361
|
}
|
|
182
|
-
|
|
362
|
+
|
|
183
363
|
if (options.vars) {
|
|
184
|
-
// Parse --vars "key=val,key2=val2"
|
|
185
364
|
const pairs = options.vars.split(',').map((p: string) => p.trim());
|
|
186
365
|
for (const pair of pairs) {
|
|
187
366
|
const [k, ...v] = pair.split('=');
|
|
@@ -192,8 +371,7 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
192
371
|
}
|
|
193
372
|
|
|
194
373
|
if (!options.yes) {
|
|
195
|
-
|
|
196
|
-
for (const v of template.variables) {
|
|
374
|
+
for (const v of mergedVarsDef) {
|
|
197
375
|
if (!variables[v.name]) {
|
|
198
376
|
const answer = await inquirer.prompt({
|
|
199
377
|
type: 'input',
|
|
@@ -205,11 +383,15 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
205
383
|
}
|
|
206
384
|
}
|
|
207
385
|
} else {
|
|
208
|
-
|
|
209
|
-
for (const v of template.variables) {
|
|
386
|
+
for (const v of mergedVarsDef) {
|
|
210
387
|
if (!variables[v.name]) {
|
|
211
388
|
if (v.required) {
|
|
212
|
-
|
|
389
|
+
const msg = `Variable "${v.name}" is required but was not provided in non-interactive mode. Use --vars ${v.name}=value`;
|
|
390
|
+
if (options.json) {
|
|
391
|
+
console.error(JSON.stringify({ status: 'error', message: msg }));
|
|
392
|
+
} else {
|
|
393
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
394
|
+
}
|
|
213
395
|
process.exit(1);
|
|
214
396
|
} else {
|
|
215
397
|
variables[v.name] = v.default || '';
|
|
@@ -219,140 +401,256 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
219
401
|
}
|
|
220
402
|
}
|
|
221
403
|
|
|
222
|
-
// 1. Create structure
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
404
|
+
// 1. Create structure (deep merge folders across all templates)
|
|
405
|
+
let mergedFolders: FolderNode[] = [];
|
|
406
|
+
for (const { template } of loadedTemplates) {
|
|
407
|
+
if (template.folders) {
|
|
408
|
+
mergedFolders = mergeFolderNodes(mergedFolders, template.folders);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
createStructure(resolvedDest, mergedFolders, options.dryRun, options.json);
|
|
412
|
+
|
|
413
|
+
// 2. Readme renaming logic
|
|
414
|
+
const templatesWithReadme: LoadedTemplate[] = [];
|
|
415
|
+
for (const lt of loadedTemplates) {
|
|
416
|
+
const hasReadme = (lt.template.copy_files || []).some(cf => isRootReadme(cf.dest || cf.src));
|
|
417
|
+
if (hasReadme) {
|
|
418
|
+
templatesWithReadme.push(lt);
|
|
419
|
+
}
|
|
230
420
|
}
|
|
231
421
|
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
422
|
+
const createdReadmes: string[] = [];
|
|
423
|
+
if (templatesWithReadme.length > 1) {
|
|
424
|
+
// Multiple templates have a root readme -> rename based on origin template name while preserving case
|
|
425
|
+
for (const lt of templatesWithReadme) {
|
|
426
|
+
for (const cf of lt.template.copy_files || []) {
|
|
427
|
+
const targetDest = cf.dest || cf.src;
|
|
428
|
+
if (isRootReadme(targetDest)) {
|
|
429
|
+
const baseName = path.basename(targetDest);
|
|
430
|
+
const match = baseName.match(/^(readme)(.*)(\.md)$/i);
|
|
431
|
+
let newDest: string;
|
|
432
|
+
if (match) {
|
|
433
|
+
newDest = `${match[1]}${match[2]}_${lt.name}${match[3]}`;
|
|
434
|
+
} else {
|
|
435
|
+
newDest = `readme_${lt.name}.md`;
|
|
436
|
+
}
|
|
437
|
+
cf.dest = newDest;
|
|
438
|
+
createdReadmes.push(newDest);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} else if (templatesWithReadme.length === 1) {
|
|
443
|
+
// Exactly one template has a root readme -> keep standard name
|
|
444
|
+
for (const cf of templatesWithReadme[0].template.copy_files || []) {
|
|
445
|
+
const targetDest = cf.dest || cf.src;
|
|
446
|
+
if (isRootReadme(targetDest)) {
|
|
447
|
+
createdReadmes.push(targetDest);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
237
450
|
}
|
|
238
451
|
|
|
452
|
+
// 3. Process copy_files for each template
|
|
453
|
+
const collisionMode = options.collision || 'overwrite';
|
|
454
|
+
for (const lt of loadedTemplates) {
|
|
455
|
+
const template = lt.template;
|
|
456
|
+
const templateRootExists = template.templateRoot && fs.existsSync(template.templateRoot);
|
|
457
|
+
if (template.templateRoot && !templateRootExists) {
|
|
458
|
+
if (!options.json) {
|
|
459
|
+
console.warn(chalk.yellow(`\nWarning: Template source directory not found: ${template.templateRoot}`));
|
|
460
|
+
console.warn(chalk.gray("Folder structure created, but files/boilerplate will be skipped."));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
239
463
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
464
|
+
if (template.copy_files && templateRootExists) {
|
|
465
|
+
if (!options.json) {
|
|
466
|
+
if (options.dryRun) console.log(chalk.yellow(`[DRY RUN] Processing copy_files for ${lt.name}...`));
|
|
467
|
+
else console.log(chalk.cyan(`Processing copy_files for ${lt.name}...`));
|
|
468
|
+
}
|
|
469
|
+
await processCopyFiles(
|
|
470
|
+
template.templateRoot!,
|
|
471
|
+
resolvedDest,
|
|
472
|
+
template,
|
|
473
|
+
variables,
|
|
474
|
+
options.dryRun,
|
|
475
|
+
collisionMode,
|
|
476
|
+
options.json
|
|
477
|
+
);
|
|
478
|
+
}
|
|
244
479
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
480
|
+
// 4. Process post_copy (executable scripts)
|
|
481
|
+
if (template.post_copy && templateRootExists) {
|
|
482
|
+
if (!options.json) {
|
|
483
|
+
if (options.dryRun) console.log(chalk.yellow(`[DRY RUN] Processing post_copy for ${lt.name}...`));
|
|
484
|
+
else console.log(chalk.cyan(`Processing post_copy for ${lt.name}...`));
|
|
485
|
+
}
|
|
248
486
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
487
|
+
for (const file of template.post_copy) {
|
|
488
|
+
const srcPath = path.join(template.templateRoot!, file.src);
|
|
489
|
+
const destPath = path.join(resolvedDest, sanitizePath(file.dest || file.src));
|
|
255
490
|
|
|
256
|
-
|
|
491
|
+
if (fs.existsSync(srcPath)) {
|
|
492
|
+
if (options.dryRun) {
|
|
493
|
+
if (!options.json) {
|
|
494
|
+
console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
|
|
495
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
496
|
+
}
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
257
499
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
500
|
+
if (collisionMode === 'newest' && fs.existsSync(destPath)) {
|
|
501
|
+
const destStat = fs.statSync(destPath);
|
|
502
|
+
const srcStat = fs.statSync(srcPath);
|
|
503
|
+
if (destStat.mtimeMs > srcStat.mtimeMs) {
|
|
504
|
+
if (!options.json) {
|
|
505
|
+
console.log(chalk.yellow(` [COLLISION] Destination is newer, keeping ${file.dest || file.src}`));
|
|
506
|
+
}
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let fileContent = fs.readFileSync(srcPath, 'utf-8');
|
|
512
|
+
if (mergedVarsDef.length > 0) {
|
|
513
|
+
fileContent = substituteVariables(fileContent, variables);
|
|
514
|
+
}
|
|
263
515
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
516
|
+
const destDir = path.dirname(destPath);
|
|
517
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
518
|
+
fs.writeFileSync(destPath, fileContent);
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
const srcStat = fs.statSync(srcPath);
|
|
522
|
+
fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
|
|
523
|
+
} catch (e) {}
|
|
524
|
+
|
|
525
|
+
if (!options.json) console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
526
|
+
} else if (!options.json) {
|
|
527
|
+
console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
|
|
275
528
|
}
|
|
276
|
-
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
277
|
-
} else {
|
|
278
|
-
console.warn(chalk.yellow(" ! " + file.src + " not found, skipping"));
|
|
279
529
|
}
|
|
280
530
|
}
|
|
281
531
|
}
|
|
282
|
-
|
|
532
|
+
|
|
533
|
+
// 5. Write .info.md
|
|
283
534
|
if (!options.dryRun) {
|
|
284
|
-
|
|
535
|
+
let infoContent = '';
|
|
536
|
+
if (loadedTemplates.length === 1) {
|
|
537
|
+
infoContent = `# ${loadedTemplates[0].name}\n\n${loadedTemplates[0].template.description || ''}\n`;
|
|
538
|
+
} else {
|
|
539
|
+
infoContent = `# ${templateNames.join(', ')}\n\n`;
|
|
540
|
+
for (const lt of loadedTemplates) {
|
|
541
|
+
infoContent += `## ${lt.name}\n${lt.template.description || ''}\n\n`;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
285
544
|
fs.writeFileSync(path.join(resolvedDest, '.info.md'), infoContent);
|
|
286
|
-
} else {
|
|
545
|
+
} else if (!options.json) {
|
|
287
546
|
console.log(chalk.gray(` [DRY RUN] Would create .info.md`));
|
|
288
547
|
}
|
|
289
548
|
|
|
290
|
-
//
|
|
291
|
-
|
|
549
|
+
// 6. Collect and deduplicate post_config tasks from all templates
|
|
550
|
+
// Key: command|description -> { task, templates: string[], _id: string }
|
|
551
|
+
const taskMap = new Map<string, PostConfigTask & { templates: string[]; _id: string }>();
|
|
552
|
+
let taskIdCounter = 0;
|
|
553
|
+
|
|
554
|
+
for (const lt of loadedTemplates) {
|
|
555
|
+
if (lt.template.post_config) {
|
|
556
|
+
for (const t of lt.template.post_config) {
|
|
557
|
+
if (!t.type || t.type === lt.name) {
|
|
558
|
+
const key = `${t.command || t.script || ''}|${t.description || ''}`;
|
|
559
|
+
if (taskMap.has(key)) {
|
|
560
|
+
taskMap.get(key)!.templates.push(lt.name);
|
|
561
|
+
} else {
|
|
562
|
+
taskMap.set(key, {
|
|
563
|
+
...t,
|
|
564
|
+
templates: [lt.name],
|
|
565
|
+
_id: `task_${taskIdCounter++}`
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const allTasks = Array.from(taskMap.values());
|
|
292
574
|
|
|
293
575
|
if (allTasks.length > 0 && !options.skipPostConfig) {
|
|
294
|
-
// SECURITY CHECK: Validate template safety
|
|
576
|
+
// SECURITY CHECK: Validate template safety (aggregate across all templates)
|
|
295
577
|
const { validateTemplateSecurity } = await import('../safety.js');
|
|
296
|
-
const { valid, errors, warnings } = validateTemplateSecurity(template);
|
|
297
578
|
|
|
298
|
-
|
|
299
|
-
|
|
579
|
+
// Collect all errors and warnings from all templates first
|
|
580
|
+
const allErrors: Array<{ template: string; error: string }> = [];
|
|
581
|
+
const allWarnings: Array<{ template: string; warning: string }> = [];
|
|
582
|
+
|
|
583
|
+
for (const lt of loadedTemplates) {
|
|
584
|
+
const { valid, errors, warnings } = validateTemplateSecurity(lt.template);
|
|
585
|
+
|
|
300
586
|
for (const err of errors) {
|
|
301
|
-
|
|
587
|
+
allErrors.push({ template: lt.name, error: err });
|
|
588
|
+
}
|
|
589
|
+
for (const warn of warnings) {
|
|
590
|
+
allWarnings.push({ template: lt.name, warning: warn });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Handle errors - any blocked command aborts everything
|
|
595
|
+
if (allErrors.length > 0) {
|
|
596
|
+
if (!options.json) {
|
|
597
|
+
console.error(chalk.red(`\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:`));
|
|
598
|
+
for (const { template, error } of allErrors) {
|
|
599
|
+
console.error(chalk.red(` [${template}] ${error}`));
|
|
600
|
+
}
|
|
302
601
|
}
|
|
303
602
|
process.exit(1);
|
|
304
603
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
console.warn(chalk.yellow(
|
|
604
|
+
|
|
605
|
+
// Handle warnings - single aggregated prompt for all templates
|
|
606
|
+
if (allWarnings.length > 0) {
|
|
607
|
+
if (!options.json) {
|
|
608
|
+
console.warn(chalk.yellow(`\n⚠️ SECURITY WARNING: Post-config tasks contain dangerous commands:`));
|
|
609
|
+
for (const { template, warning } of allWarnings) {
|
|
610
|
+
console.warn(chalk.yellow(` [${template}] ${warning}`));
|
|
611
|
+
}
|
|
310
612
|
}
|
|
311
613
|
|
|
312
614
|
if (!options.yes) {
|
|
313
615
|
const { proceed } = await inquirer.prompt({
|
|
314
616
|
type: 'confirm',
|
|
315
617
|
name: 'proceed',
|
|
316
|
-
message: chalk.red(
|
|
618
|
+
message: chalk.red(`Security warnings found in ${new Set(allWarnings.map(w => w.template)).size} template(s). Run post-config tasks anyway?`),
|
|
317
619
|
default: false
|
|
318
620
|
});
|
|
319
621
|
if (!proceed) {
|
|
320
|
-
console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
622
|
+
if (!options.json) console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
321
623
|
return;
|
|
322
624
|
}
|
|
323
|
-
} else {
|
|
625
|
+
} else if (!options.json) {
|
|
324
626
|
console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
|
|
325
627
|
}
|
|
326
628
|
}
|
|
327
|
-
// Determine which tasks to include
|
|
328
|
-
let selectedTaskNames: string[] = [];
|
|
329
629
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
630
|
+
let selectedTaskIds: string[] = [];
|
|
631
|
+
|
|
632
|
+
if (options.dryRun) {
|
|
633
|
+
selectedTaskIds = allTasks.map(t => t._id);
|
|
634
|
+
if (!options.json) {
|
|
635
|
+
console.log(chalk.yellow(`\n[DRY RUN] Applicable post-config tasks:`));
|
|
636
|
+
for (const t of allTasks) {
|
|
637
|
+
const desc = t.description ? ` (${t.description})` : '';
|
|
638
|
+
const templatesNote = t.templates.length > 1 ? ` [${t.templates.join(', ')}]` : ` [${t.templates[0]}]`;
|
|
639
|
+
console.log(chalk.gray(` ${t.command || `./${t.script}`}${desc}${templatesNote}`));
|
|
640
|
+
}
|
|
340
641
|
}
|
|
341
642
|
} else if (options.yes) {
|
|
342
|
-
|
|
343
|
-
selectedTaskNames = allTasks.map(t => t.command || `./${t.script}` || '');
|
|
344
|
-
} else if (allTasks.length === 0) {
|
|
345
|
-
selectedTaskNames = [];
|
|
643
|
+
selectedTaskIds = allTasks.map(t => t._id);
|
|
346
644
|
} else {
|
|
347
|
-
// Checkbox prompt
|
|
348
645
|
const choices: Array<{name: string; value: string; checked?: boolean}> = [];
|
|
349
646
|
|
|
350
647
|
for (const t of allTasks) {
|
|
351
648
|
const cmd = t.command || `./${t.script}` || '(no command)';
|
|
352
649
|
const desc = t.description ? ` (${t.description})` : '';
|
|
650
|
+
const templatesNote = t.templates.length > 1 ? ` [${t.templates.join(', ')}]` : ` [${t.templates[0]}]`;
|
|
353
651
|
choices.push({
|
|
354
|
-
name: `${cmd}${desc}`,
|
|
355
|
-
value:
|
|
652
|
+
name: `${cmd}${desc}${templatesNote}`,
|
|
653
|
+
value: t._id,
|
|
356
654
|
checked: true
|
|
357
655
|
});
|
|
358
656
|
}
|
|
@@ -370,27 +668,23 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
370
668
|
},
|
|
371
669
|
choices
|
|
372
670
|
});
|
|
373
|
-
|
|
671
|
+
selectedTaskIds = response.selected || [];
|
|
374
672
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (selectedTaskNames.length > 0 && !options.dryRun) {
|
|
673
|
+
|
|
674
|
+
if (selectedTaskIds.length > 0 && !options.dryRun) {
|
|
378
675
|
let bashContent = '#!/bin/bash\n# Auto-generated post_config script\n\n';
|
|
379
676
|
let batContent = '@echo off\n:: Auto-generated post_config script\n\n';
|
|
380
677
|
for (const t of allTasks) {
|
|
381
|
-
// Determine the actual command/script to use
|
|
382
678
|
let cmd = '';
|
|
383
679
|
if (t.command) {
|
|
384
680
|
cmd = t.command;
|
|
385
681
|
} else if (t.script) {
|
|
386
682
|
cmd = `./${t.script}`;
|
|
387
683
|
}
|
|
388
|
-
|
|
389
|
-
const taskKey = t.command || (t.script ? `./${t.script}` : '');
|
|
390
|
-
if (selectedTaskNames.includes(taskKey)) {
|
|
684
|
+
if (selectedTaskIds.includes(t._id)) {
|
|
391
685
|
if (cmd) {
|
|
392
|
-
bashContent += `echo "Running: ${t.description ||
|
|
393
|
-
batContent += `echo Running: ${t.description ||
|
|
686
|
+
bashContent += `echo "Running: ${t.description || cmd}"\n${cmd}\n`;
|
|
687
|
+
batContent += `echo Running: ${t.description || cmd}\n${cmd}\n`;
|
|
394
688
|
}
|
|
395
689
|
}
|
|
396
690
|
}
|
|
@@ -398,50 +692,57 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
398
692
|
try { fs.chmodSync(path.join(resolvedDest, 'post_config.sh'), 0o755); } catch(e) {}
|
|
399
693
|
fs.writeFileSync(path.join(resolvedDest, 'post_config.bat'), batContent);
|
|
400
694
|
|
|
401
|
-
|
|
402
|
-
console.log(chalk.cyan("\nExecuting post-config tasks..."));
|
|
695
|
+
if (!options.json) console.log(chalk.cyan("\nExecuting post-config tasks..."));
|
|
403
696
|
try {
|
|
404
697
|
const scriptCmd = process.platform === 'win32' ? 'post_config.bat' : './post_config.sh';
|
|
405
|
-
execSync(scriptCmd, {
|
|
406
|
-
cwd: resolvedDest,
|
|
407
|
-
stdio: 'inherit'
|
|
698
|
+
execSync(scriptCmd, {
|
|
699
|
+
cwd: resolvedDest,
|
|
700
|
+
stdio: options.json ? 'ignore' : 'inherit'
|
|
408
701
|
});
|
|
409
702
|
} catch (e) {
|
|
410
|
-
console.error(chalk.red("\nError: Some post-config tasks failed. Check the output above."));
|
|
703
|
+
if (!options.json) console.error(chalk.red("\nError: Some post-config tasks failed. Check the output above."));
|
|
411
704
|
}
|
|
412
705
|
}
|
|
413
706
|
}
|
|
414
707
|
|
|
415
|
-
if (options.
|
|
708
|
+
if (options.json) {
|
|
709
|
+
const result = {
|
|
710
|
+
status: 'success',
|
|
711
|
+
dryRun: !!options.dryRun,
|
|
712
|
+
dest: resolvedDest,
|
|
713
|
+
templates: templateNames,
|
|
714
|
+
variables,
|
|
715
|
+
readmes: createdReadmes
|
|
716
|
+
};
|
|
717
|
+
console.log(JSON.stringify(result, null, 2));
|
|
718
|
+
} else if (options.dryRun) {
|
|
416
719
|
console.log(chalk.yellow(`\n[DRY RUN] Project initialization preview complete.`));
|
|
417
720
|
} else {
|
|
418
721
|
console.log(chalk.green(`\n✓ Project created successfully.`));
|
|
419
722
|
}
|
|
420
723
|
}
|
|
421
724
|
|
|
422
|
-
function createStructure(dirPath: string, folders: FolderNode[], dryRun: boolean = false) {
|
|
725
|
+
function createStructure(dirPath: string, folders: FolderNode[], dryRun: boolean = false, silent: boolean = false) {
|
|
423
726
|
for (const folder of folders) {
|
|
424
727
|
const fullDirPath = path.join(dirPath, sanitizePath(folder.name));
|
|
425
728
|
|
|
426
729
|
if (dryRun) {
|
|
427
|
-
console.log(chalk.gray(` [DRY RUN] Would create directory: ${fullDirPath}`));
|
|
730
|
+
if (!silent) console.log(chalk.gray(` [DRY RUN] Would create directory: ${fullDirPath}`));
|
|
428
731
|
} else {
|
|
429
732
|
fs.mkdirSync(fullDirPath, { recursive: true });
|
|
430
733
|
}
|
|
431
734
|
|
|
432
|
-
// Create .info.md if content exists
|
|
433
735
|
if (folder.info) {
|
|
434
736
|
const infoPath = path.join(fullDirPath, '.info.md');
|
|
435
737
|
if (dryRun) {
|
|
436
|
-
console.log(chalk.gray(` [DRY RUN] Would create info file: ${infoPath}`));
|
|
738
|
+
if (!silent) console.log(chalk.gray(` [DRY RUN] Would create info file: ${infoPath}`));
|
|
437
739
|
} else {
|
|
438
740
|
fs.writeFileSync(infoPath, folder.info);
|
|
439
741
|
}
|
|
440
742
|
}
|
|
441
743
|
|
|
442
|
-
// Recurse children
|
|
443
744
|
if (folder.children && folder.children.length > 0) {
|
|
444
|
-
createStructure(fullDirPath, folder.children, dryRun);
|
|
745
|
+
createStructure(fullDirPath, folder.children, dryRun, silent);
|
|
445
746
|
}
|
|
446
747
|
}
|
|
447
748
|
}
|