@garyr/pt-cli 1.1.0 → 1.3.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/CHANGELOG.md +47 -0
- package/README.md +11 -6
- package/dist/commands/completionCommand.js +7 -16
- package/dist/commands/initCommand.js +476 -181
- package/dist/commands/learnCommand.js +28 -7
- package/dist/commands/template-utils.js +17 -7
- package/dist/commands/updateCommand.js +56 -25
- package/dist/config.js +3 -0
- package/dist/index.js +23 -5
- package/dist/substitute.js +45 -19
- package/doc/configuration.md +7 -5
- package/doc/usage.md +22 -5
- package/package.json +4 -4
- package/skills/agency-pt-operator/SKILL.md +5 -4
- package/src/commands/completionCommand.ts +7 -15
- package/src/commands/initCommand.ts +495 -194
- package/src/commands/learnCommand.ts +29 -7
- package/src/commands/template-utils.ts +17 -8
- package/src/commands/updateCommand.ts +125 -91
- package/src/config.ts +3 -0
- package/src/index.ts +21 -5
- package/src/substitute.ts +48 -21
- package/tests/config-utils.test.ts +1 -1
- package/tests/learn.test.ts +5 -4
- package/tests/modularity.test.ts +605 -0
- package/tests/update.test.ts +63 -9
|
@@ -281,14 +281,21 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
281
281
|
folders = existingTemplate.folders;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// selectedStructure should include ALL folders (existing + added) in additive mode
|
|
285
|
+
// so that the final filter doesn't drop user-selected new folders
|
|
286
|
+
selectedStructure = [
|
|
287
|
+
...new Set([
|
|
288
|
+
...existingTemplate.folders?.map((f: FolderNode) => f.name) || [],
|
|
289
|
+
...folders.filter(f => !existingTemplate.folders?.some(ef => ef.name === f.name)).map(f => f.name),
|
|
290
|
+
]),
|
|
291
|
+
];
|
|
292
|
+
|
|
284
293
|
// New files
|
|
285
294
|
const newFiles = rootFiles.filter((f: string) => !existingTemplate.copy_files?.some((cf: CopyFileEntry) => cf.src === f));
|
|
286
295
|
printNewFiles(newFiles.length, newFiles);
|
|
287
296
|
const addedFiles = await promptNewFiles(newFiles, options);
|
|
288
297
|
selectedFiles = [...(existingTemplate.copy_files?.filter((cf: CopyFileEntry) => !rootDirs.includes(cf.src)).map((cf: CopyFileEntry) => cf.src) || []), ...addedFiles];
|
|
289
298
|
|
|
290
|
-
// Structure
|
|
291
|
-
selectedStructure = existingTemplate.folders?.map((f: FolderNode) => f.name) || [];
|
|
292
299
|
// Seed selectedFolders from existing copy_files directory entries
|
|
293
300
|
selectedFolders = (existingTemplate.copy_files || [])
|
|
294
301
|
.filter((f: CopyFileEntry) => rootDirs.includes(f.src))
|
|
@@ -311,7 +318,14 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
311
318
|
|
|
312
319
|
// --- COPY FILES ---
|
|
313
320
|
const existingCopyFiles = isUpdate ? config.templates[updateTemplate].copy_files || [] : [];
|
|
314
|
-
|
|
321
|
+
|
|
322
|
+
// If JSON template config has copy_files, use those directly (for new templates)
|
|
323
|
+
let copy_files: CopyFileEntry[];
|
|
324
|
+
if (!isUpdate && fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
|
|
325
|
+
copy_files = [...fileTemplateConfig.copy_files];
|
|
326
|
+
} else {
|
|
327
|
+
copy_files = buildCopyFiles(selectedFiles, selectedFolders, existingCopyFiles);
|
|
328
|
+
}
|
|
315
329
|
|
|
316
330
|
const templateConfig: TemplateConfig = {
|
|
317
331
|
description: description,
|
|
@@ -360,8 +374,18 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
360
374
|
.filter(file => isExecutable(path.join(resolvedPath, file), file))
|
|
361
375
|
.filter(file => !shouldExcludeFile(file));
|
|
362
376
|
|
|
377
|
+
// In additive mode (isUpdate), only add executables that were explicitly selected by the user
|
|
378
|
+
let selectedExecutables: string[] = [];
|
|
379
|
+
if (isUpdate) {
|
|
380
|
+
// In additive mode, only include executables that are in selectedFiles (user explicitly chose them)
|
|
381
|
+
selectedExecutables = detectedExecutables.filter(exec => selectedFiles.includes(exec));
|
|
382
|
+
} else {
|
|
383
|
+
// In new template mode, include all detected executables (original behavior)
|
|
384
|
+
selectedExecutables = detectedExecutables;
|
|
385
|
+
}
|
|
386
|
+
|
|
363
387
|
const existingPostCopy = isUpdate ? config.templates[updateTemplate].post_copy || [] : [];
|
|
364
|
-
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy,
|
|
388
|
+
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy, selectedExecutables);
|
|
365
389
|
|
|
366
390
|
if (post_copy.length > 0) {
|
|
367
391
|
templateConfig.post_copy = post_copy;
|
|
@@ -372,9 +396,7 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
372
396
|
// --- OUTPUT ---
|
|
373
397
|
if (options.json) {
|
|
374
398
|
const output = { name: targetName, ...templateConfig };
|
|
375
|
-
process.stdout.write(JSON.stringify(output, null, 2) + '\n'
|
|
376
|
-
process.exit(0);
|
|
377
|
-
});
|
|
399
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
378
400
|
return;
|
|
379
401
|
}
|
|
380
402
|
|
|
@@ -151,7 +151,9 @@ export function getRootEntries(dirPath: string, ignorePatterns?: string[]): { fi
|
|
|
151
151
|
.filter(e => !shouldExclude(dirPath, path.join(dirPath, e.name), ignorePatterns))
|
|
152
152
|
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
153
153
|
|
|
154
|
-
const files = entries.filter(e => e.isFile())
|
|
154
|
+
const files = entries.filter(e => e.isFile())
|
|
155
|
+
.map(e => e.name)
|
|
156
|
+
.filter(fileName => !shouldExcludeFile(fileName));
|
|
155
157
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
156
158
|
return { files, dirs };
|
|
157
159
|
}
|
|
@@ -207,16 +209,25 @@ export function mergePostConfigTasks(
|
|
|
207
209
|
if (jsonTasks && Array.isArray(jsonTasks)) {
|
|
208
210
|
return [...jsonTasks];
|
|
209
211
|
}
|
|
210
|
-
|
|
212
|
+
// Merge detected tasks with existing, avoiding duplicates
|
|
213
|
+
const merged = [...existingTasks];
|
|
214
|
+
for (const dt of detectedTasks) {
|
|
215
|
+
const exists = merged.some(et => et.command === dt.command && et.script === dt.script);
|
|
216
|
+
if (!exists) {
|
|
217
|
+
merged.push(dt);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return merged;
|
|
211
221
|
}
|
|
212
222
|
|
|
213
223
|
/**
|
|
214
224
|
* Merge post_copy files from existing, JSON file, and detected executables
|
|
225
|
+
* Only adds executables that were explicitly selected by the user
|
|
215
226
|
*/
|
|
216
227
|
export function mergePostCopyFiles(
|
|
217
228
|
existingPostCopy: PostCopyFile[],
|
|
218
229
|
jsonPostCopy: PostCopyFile[] | undefined,
|
|
219
|
-
|
|
230
|
+
selectedExecutables: string[]
|
|
220
231
|
): PostCopyFile[] {
|
|
221
232
|
let post_copy = [...existingPostCopy];
|
|
222
233
|
|
|
@@ -228,11 +239,9 @@ export function mergePostCopyFiles(
|
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
241
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// In interactive mode, we'd prompt to add these - for now just auto-add
|
|
235
|
-
for (const file of newExecutables) {
|
|
242
|
+
// Only add executables that were explicitly selected by the user
|
|
243
|
+
for (const file of selectedExecutables) {
|
|
244
|
+
if (!post_copy.some(existing => existing.src === file)) {
|
|
236
245
|
post_copy.push({ src: file, dest: file });
|
|
237
246
|
}
|
|
238
247
|
}
|
|
@@ -170,6 +170,7 @@ export async function update(sourcePath: string, templateName: string, options:
|
|
|
170
170
|
if (!isFullMode) {
|
|
171
171
|
// Additive mode: only present new variables for selection
|
|
172
172
|
const existingVarNames = new Set(config.templates[templateName].variables?.map(v => v.name) || []);
|
|
173
|
+
// newVars are NOT added to variables yet - only existing variables + JSON variables are in variables
|
|
173
174
|
const newVars = variables.filter(v => !existingVarNames.has(v.name));
|
|
174
175
|
|
|
175
176
|
if (newVars.length > 0) {
|
|
@@ -177,6 +178,7 @@ export async function update(sourcePath: string, templateName: string, options:
|
|
|
177
178
|
console.log(chalk.green(` + ${newVars.length} new variable(s): ${newVars.map(v => v.name).join(', ')}`));
|
|
178
179
|
|
|
179
180
|
const selectedNewVars = await promptNewVariables(newVars, options);
|
|
181
|
+
// Only add the selected new variables (not all newVars)
|
|
180
182
|
variables.push(...selectedNewVars);
|
|
181
183
|
} else {
|
|
182
184
|
printNoNewVariables();
|
|
@@ -197,106 +199,119 @@ export async function update(sourcePath: string, templateName: string, options:
|
|
|
197
199
|
variables.push(...selectedGlobals);
|
|
198
200
|
}
|
|
199
201
|
} else {
|
|
200
|
-
// Full mode:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
202
|
+
// Full mode: replace all variables with detected ones (no additive)
|
|
203
|
+
variables = [];
|
|
204
|
+
// Add detected variables
|
|
205
|
+
for (const varName of detectedVars) {
|
|
206
|
+
variables.push({
|
|
207
|
+
name: varName,
|
|
208
|
+
prompt: `Enter ${varName}:`,
|
|
209
|
+
required: true
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// Also include JSON variables
|
|
213
|
+
if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
|
|
214
|
+
for (const v of fileTemplateConfig.variables) {
|
|
215
|
+
const existingIndex = variables.findIndex(existing => existing.name === v.name);
|
|
216
|
+
if (existingIndex !== -1) {
|
|
217
|
+
variables[existingIndex] = { ...variables[existingIndex], ...v };
|
|
218
|
+
} else {
|
|
219
|
+
variables.push({ ...v });
|
|
206
220
|
}
|
|
207
221
|
}
|
|
208
222
|
}
|
|
209
|
-
|
|
210
|
-
if (globalVarsToPrompt.length > 0) {
|
|
211
|
-
const selectedGlobals = await promptGlobalVariables(globalVarsToPrompt, options);
|
|
212
|
-
variables.push(...selectedGlobals);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const additionalVars = await promptAdditionalVariables(variables, options);
|
|
216
|
-
variables.push(...additionalVars);
|
|
217
223
|
}
|
|
218
224
|
|
|
219
225
|
// 1. Structure (skeleton) - Additive mode
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
+
let folders: FolderNode[] = [];
|
|
227
|
+
let selectedStructure: string[] = [];
|
|
228
|
+
let selectedFiles: string[] = [];
|
|
229
|
+
let selectedFolders: string[] = [];
|
|
230
|
+
|
|
231
|
+
if (!isFullMode) {
|
|
232
|
+
// Additive mode: only add new folders
|
|
233
|
+
const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
234
|
+
? fileTemplateConfig.folders
|
|
235
|
+
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
226
236
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (newFolders.length > 0) {
|
|
231
|
-
console.log(chalk.cyan(`\n📊 New Folders:`));
|
|
232
|
-
console.log(chalk.green(` + ${newFolders.length} new folder(s): ${newFolders.map(f => f.name).join(', ')}`));
|
|
233
|
-
|
|
234
|
-
const addedFolders = await promptNewFolders(newFolders, options);
|
|
235
|
-
folders = [...existingFolders, ...addedFolders];
|
|
236
|
-
} else {
|
|
237
|
-
printNoNewFolders();
|
|
238
|
-
folders = existingFolders;
|
|
239
|
-
}
|
|
240
|
-
} else {
|
|
241
|
-
// Full mode: original behavior
|
|
242
|
-
folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
243
|
-
? fileTemplateConfig.folders
|
|
244
|
-
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
245
|
-
}
|
|
237
|
+
const existingFolders = config.templates[templateName].folders || [];
|
|
238
|
+
const newFolders = detectedFolders.filter(f => !existingFolders.some(ef => ef.name === f.name));
|
|
246
239
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
240
|
+
if (newFolders.length > 0) {
|
|
241
|
+
console.log(chalk.cyan(`\n📊 New Folders:`));
|
|
242
|
+
console.log(chalk.green(` + ${newFolders.length} new folder(s): ${newFolders.map(f => f.name).join(', ')}`));
|
|
251
243
|
|
|
252
|
-
|
|
253
|
-
|
|
244
|
+
const addedFolders = await promptNewFolders(newFolders, options);
|
|
245
|
+
folders = [...existingFolders, ...addedFolders];
|
|
246
|
+
} else {
|
|
247
|
+
printNoNewFolders();
|
|
248
|
+
folders = existingFolders;
|
|
249
|
+
}
|
|
254
250
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
251
|
+
// selectedStructure should include ALL folders (existing + added) in additive mode
|
|
252
|
+
// so that the final filter doesn't drop user-selected new folders
|
|
253
|
+
selectedStructure = [
|
|
254
|
+
...new Set([
|
|
255
|
+
...existingFolders.map(f => f.name),
|
|
256
|
+
...folders.filter(f => !existingFolders.some(ef => ef.name === f.name)).map(f => f.name),
|
|
257
|
+
]),
|
|
258
|
+
];
|
|
259
|
+
} else {
|
|
260
|
+
// Full mode: original behavior
|
|
261
|
+
folders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
262
|
+
? fileTemplateConfig.folders
|
|
263
|
+
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
264
|
+
}
|
|
258
265
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
266
|
+
// 2. Content Selection (Root only)
|
|
267
|
+
const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
|
268
|
+
.filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
|
|
269
|
+
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
270
|
+
|
|
271
|
+
const rootFiles = rootEntries.filter(e => e.isFile())
|
|
272
|
+
.map(e => e.name)
|
|
273
|
+
.filter(fileName => !shouldExcludeFile(fileName));
|
|
274
|
+
const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
275
|
+
|
|
276
|
+
if (!isFullMode) {
|
|
277
|
+
// Additive mode for files and folders
|
|
278
|
+
const existingCopyFiles = config.templates[templateName].copy_files || [];
|
|
262
279
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
280
|
-
const newFolders = detectedFolders.filter(f => !config.templates[templateName].folders?.some(ef => ef.name === f.name));
|
|
280
|
+
// New files
|
|
281
|
+
const newFiles = rootFiles.filter(f => !existingCopyFiles.some(cf => cf.src === f));
|
|
282
|
+
printNewFiles(newFiles.length, newFiles);
|
|
283
|
+
const addedFiles = await promptNewFiles(newFiles, options);
|
|
284
|
+
selectedFiles = [...existingCopyFiles.filter(cf => !rootDirs.includes(cf.src)).map(cf => cf.src), ...addedFiles];
|
|
285
|
+
|
|
286
|
+
// Seed selectedFolders from existing copy_files directory entries
|
|
287
|
+
selectedFolders = existingCopyFiles
|
|
288
|
+
.filter(f => rootDirs.includes(f.src))
|
|
289
|
+
.map(f => f.src);
|
|
290
|
+
|
|
291
|
+
if (rootDirs.length > 0) {
|
|
292
|
+
const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
293
|
+
? fileTemplateConfig.folders
|
|
294
|
+
: extractStructure(resolvedPath, resolvedPath, ignorePatterns);
|
|
295
|
+
const newFolders = detectedFolders.filter(f => !config.templates[templateName].folders?.some(ef => ef.name === f.name));
|
|
281
296
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
297
|
+
const addedDirs = newFolders
|
|
298
|
+
.filter(f => ['APP', 'scripts', 'bin'].some(p => f.name === p))
|
|
299
|
+
.map(f => f.name);
|
|
300
|
+
selectedStructure = [...new Set([...selectedStructure, ...addedDirs])];
|
|
301
|
+
selectedFolders = [...new Set([...selectedFolders, ...addedDirs])];
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
// Full mode: original behavior - pick up all files like a fresh learn
|
|
305
|
+
if (options.yes || options.json) {
|
|
306
|
+
selectedFiles = rootFiles;
|
|
307
|
+
selectedStructure = rootDirs;
|
|
308
|
+
selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
|
|
309
|
+
} else {
|
|
310
|
+
selectedFiles = await promptRootFiles(rootFiles, undefined, options);
|
|
311
|
+
selectedStructure = await promptStructureFolders(rootDirs, options);
|
|
312
|
+
selectedFolders = await promptCopyFolders(selectedStructure, undefined, options);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
300
315
|
|
|
301
316
|
const copy_files: CopyFileEntry[] = [];
|
|
302
317
|
if (fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
|
|
@@ -371,8 +386,29 @@ export async function update(sourcePath: string, templateName: string, options:
|
|
|
371
386
|
.filter(file => isExecutable(path.join(resolvedPath, file), file))
|
|
372
387
|
.filter(file => !shouldExcludeFile(file));
|
|
373
388
|
|
|
389
|
+
// In additive mode, only add executables that were explicitly selected by the user
|
|
390
|
+
let selectedExecutables: string[] = [];
|
|
391
|
+
if (!isFullMode) {
|
|
392
|
+
// In additive mode, only include executables that are in selectedFiles (user explicitly chose them)
|
|
393
|
+
// BUT exclude files that already exist in copy_files with custom settings (chmod, substitute_variables: false)
|
|
394
|
+
const existingCopyFiles = config.templates[templateName].copy_files || [];
|
|
395
|
+
const existingCopySrcs = new Set(existingCopyFiles.map(cf => cf.src));
|
|
396
|
+
const existingCustomSettings = new Set(
|
|
397
|
+
existingCopyFiles
|
|
398
|
+
.filter(cf => cf.chmod || cf.substitute_variables === false)
|
|
399
|
+
.map(cf => cf.src)
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
selectedExecutables = detectedExecutables.filter(exec =>
|
|
403
|
+
selectedFiles.includes(exec) && !existingCustomSettings.has(exec)
|
|
404
|
+
);
|
|
405
|
+
} else {
|
|
406
|
+
// In full mode, include all detected executables (original behavior)
|
|
407
|
+
selectedExecutables = detectedExecutables;
|
|
408
|
+
}
|
|
409
|
+
|
|
374
410
|
const existingPostCopy = config.templates[templateName].post_copy || [];
|
|
375
|
-
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy,
|
|
411
|
+
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy, selectedExecutables);
|
|
376
412
|
|
|
377
413
|
if (post_copy.length > 0) {
|
|
378
414
|
templateConfig.post_copy = post_copy;
|
|
@@ -386,9 +422,7 @@ export async function update(sourcePath: string, templateName: string, options:
|
|
|
386
422
|
...templateConfig
|
|
387
423
|
};
|
|
388
424
|
|
|
389
|
-
process.stdout.write(JSON.stringify(output, null, 2) + '\n'
|
|
390
|
-
process.exit(0);
|
|
391
|
-
});
|
|
425
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
392
426
|
return;
|
|
393
427
|
}
|
|
394
428
|
|
package/src/config.ts
CHANGED
|
@@ -285,6 +285,7 @@ export const DEFAULT_EXCLUDES = [
|
|
|
285
285
|
'dist',
|
|
286
286
|
'build',
|
|
287
287
|
'bin',
|
|
288
|
+
'.vscode',
|
|
288
289
|
'.DS_Store',
|
|
289
290
|
'Thumbs.db',
|
|
290
291
|
];
|
|
@@ -397,6 +398,8 @@ export function shouldExcludeFile(fileName: string): boolean {
|
|
|
397
398
|
'yarn.lock',
|
|
398
399
|
'pnpm-lock.yaml',
|
|
399
400
|
'composer.lock',
|
|
401
|
+
'post_config.sh',
|
|
402
|
+
'post_config.bat',
|
|
400
403
|
];
|
|
401
404
|
|
|
402
405
|
for (const pattern of excludePatterns) {
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ const program = new Command();
|
|
|
25
25
|
program
|
|
26
26
|
.name('pt')
|
|
27
27
|
.description('Project Template CLI - Learn project structures and initialize new ones')
|
|
28
|
-
.version(pkg.version, '-v', 'output the version number');
|
|
28
|
+
.version(pkg.version, '-v, --version', 'output the version number');
|
|
29
29
|
|
|
30
30
|
program
|
|
31
31
|
.command('learn [path]')
|
|
@@ -72,15 +72,31 @@ program
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
program
|
|
75
|
-
.command('init [
|
|
76
|
-
.description('Initialize a new project from
|
|
75
|
+
.command('init [args...]')
|
|
76
|
+
.description('Initialize a new project from one or more learned templates')
|
|
77
77
|
.option('-f, --file <jsonPath>', 'Initialize directly from a JSON template file without adding it to local config')
|
|
78
78
|
.option('--skip-post-config', 'Skip running post-config tasks')
|
|
79
79
|
.option('--dry-run', 'Show what would be created without making changes')
|
|
80
80
|
.option('-y, --yes', 'Automatically answer yes to prompts')
|
|
81
81
|
.option('--vars <variables>', 'Comma-separated key=value variables (e.g. key1=val1,key2=val2)')
|
|
82
|
-
.
|
|
83
|
-
|
|
82
|
+
.option('--collision <mode>', 'File collision resolution strategy (overwrite, newest)', 'overwrite')
|
|
83
|
+
.option('--json', 'Output result as JSON')
|
|
84
|
+
.action(async (args: string[], options) => {
|
|
85
|
+
try {
|
|
86
|
+
await init(args, options);
|
|
87
|
+
} catch (err: any) {
|
|
88
|
+
if (options.json) {
|
|
89
|
+
process.stdout.write(JSON.stringify({
|
|
90
|
+
status: 'error',
|
|
91
|
+
message: err.message || String(err)
|
|
92
|
+
}) + '\n', () => {
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
84
100
|
});
|
|
85
101
|
|
|
86
102
|
program
|
package/src/substitute.ts
CHANGED
|
@@ -50,7 +50,9 @@ export async function processCopyFiles(
|
|
|
50
50
|
resolvedDest: string,
|
|
51
51
|
template: TemplateConfig,
|
|
52
52
|
variables: Record<string, string>,
|
|
53
|
-
dryRun: boolean = false
|
|
53
|
+
dryRun: boolean = false,
|
|
54
|
+
collisionMode: 'overwrite' | 'newest' = 'overwrite',
|
|
55
|
+
silent: boolean = false
|
|
54
56
|
): Promise<void> {
|
|
55
57
|
if (!template.copy_files) return;
|
|
56
58
|
|
|
@@ -59,7 +61,7 @@ export async function processCopyFiles(
|
|
|
59
61
|
const destPath = path.join(resolvedDest, sanitizePath(copyFile.dest));
|
|
60
62
|
|
|
61
63
|
if (!fs.existsSync(srcPath)) {
|
|
62
|
-
console.warn(chalk.yellow(`Warning: ${copyFile.src} not found in template`));
|
|
64
|
+
if (!silent) console.warn(chalk.yellow(`Warning: ${copyFile.src} not found in template`));
|
|
63
65
|
continue;
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -67,7 +69,7 @@ export async function processCopyFiles(
|
|
|
67
69
|
if (stat.isDirectory()) {
|
|
68
70
|
// Recursive directory copy
|
|
69
71
|
if (dryRun) {
|
|
70
|
-
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
72
|
+
if (!silent) console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
71
73
|
} else {
|
|
72
74
|
const dirSubstitute = !!(copyFile.substitute_variables === true || (
|
|
73
75
|
copyFile.substitute_variables === undefined &&
|
|
@@ -75,24 +77,39 @@ export async function processCopyFiles(
|
|
|
75
77
|
template.variables.length > 0 &&
|
|
76
78
|
Object.keys(variables).length > 0
|
|
77
79
|
));
|
|
78
|
-
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
80
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod, collisionMode, silent);
|
|
79
81
|
}
|
|
80
|
-
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
82
|
+
if (!silent) console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
81
83
|
} else {
|
|
84
|
+
// Check collision mode
|
|
85
|
+
if (collisionMode === 'newest' && fs.existsSync(destPath)) {
|
|
86
|
+
const destStat = fs.statSync(destPath);
|
|
87
|
+
if (destStat.mtimeMs > stat.mtimeMs) {
|
|
88
|
+
if (dryRun && !silent) {
|
|
89
|
+
console.log(chalk.yellow(` [DRY RUN] [COLLISION] Destination is newer, keeping ${copyFile.dest}`));
|
|
90
|
+
} else if (!silent) {
|
|
91
|
+
console.log(chalk.yellow(` [COLLISION] Destination is newer, keeping ${copyFile.dest}`));
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
// Single file copy
|
|
83
98
|
if (dryRun) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
copyFile.substitute_variables ===
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
if (!silent) {
|
|
100
|
+
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
101
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (
|
|
102
|
+
copyFile.substitute_variables === undefined &&
|
|
103
|
+
template.variables &&
|
|
104
|
+
template.variables.length > 0 &&
|
|
105
|
+
Object.keys(variables).length > 0
|
|
106
|
+
));
|
|
107
|
+
if (drySubstitute) {
|
|
108
|
+
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
109
|
+
}
|
|
110
|
+
if (copyFile.chmod) {
|
|
111
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod ${copyFile.chmod} ${copyFile.dest}`));
|
|
112
|
+
}
|
|
96
113
|
}
|
|
97
114
|
continue;
|
|
98
115
|
}
|
|
@@ -119,13 +136,13 @@ export async function processCopyFiles(
|
|
|
119
136
|
try {
|
|
120
137
|
fs.chmodSync(destPath, parseInt(copyFile.chmod, 8));
|
|
121
138
|
} catch (e) {
|
|
122
|
-
if (process.platform !== 'win32') {
|
|
139
|
+
if (process.platform !== 'win32' && !silent) {
|
|
123
140
|
console.error(chalk.red(`Failed to set chmod ${copyFile.chmod} on ${copyFile.dest}`));
|
|
124
141
|
}
|
|
125
142
|
}
|
|
126
143
|
}
|
|
127
144
|
|
|
128
|
-
console.log(chalk.green(` ✓ ${copyFile.dest}`));
|
|
145
|
+
if (!silent) console.log(chalk.green(` ✓ ${copyFile.dest}`));
|
|
129
146
|
}
|
|
130
147
|
}
|
|
131
148
|
}
|
|
@@ -135,7 +152,9 @@ function copyDirRecursive(
|
|
|
135
152
|
dest: string,
|
|
136
153
|
variables: Record<string, string>,
|
|
137
154
|
substitute: boolean,
|
|
138
|
-
chmod?: string
|
|
155
|
+
chmod?: string,
|
|
156
|
+
collisionMode: 'overwrite' | 'newest' = 'overwrite',
|
|
157
|
+
silent: boolean = false
|
|
139
158
|
) {
|
|
140
159
|
fs.mkdirSync(dest, { recursive: true });
|
|
141
160
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
@@ -145,8 +164,16 @@ function copyDirRecursive(
|
|
|
145
164
|
const destPath = path.join(dest, entry.name);
|
|
146
165
|
|
|
147
166
|
if (entry.isDirectory()) {
|
|
148
|
-
copyDirRecursive(srcPath, destPath, variables, substitute, chmod);
|
|
167
|
+
copyDirRecursive(srcPath, destPath, variables, substitute, chmod, collisionMode, silent);
|
|
149
168
|
} else {
|
|
169
|
+
if (collisionMode === 'newest' && fs.existsSync(destPath)) {
|
|
170
|
+
const destStat = fs.statSync(destPath);
|
|
171
|
+
const srcStat = fs.statSync(srcPath);
|
|
172
|
+
if (destStat.mtimeMs > srcStat.mtimeMs) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
151
178
|
if (substitute) {
|
|
152
179
|
content = substituteVariables(content, variables);
|
|
@@ -147,9 +147,9 @@ test('DEFAULT_EXCLUDES contains expected patterns', () => {
|
|
|
147
147
|
assert.ok(DEFAULT_EXCLUDES.includes('node_modules'), 'Should include node_modules');
|
|
148
148
|
assert.ok(DEFAULT_EXCLUDES.includes('dist'), 'Should include dist');
|
|
149
149
|
assert.ok(DEFAULT_EXCLUDES.includes('build'), 'Should include build');
|
|
150
|
+
assert.ok(DEFAULT_EXCLUDES.includes('.vscode'), 'Should include .vscode');
|
|
150
151
|
assert.ok(DEFAULT_EXCLUDES.includes('.DS_Store'), 'Should include .DS_Store');
|
|
151
152
|
assert.ok(DEFAULT_EXCLUDES.includes('Thumbs.db'), 'Should include Thumbs.db');
|
|
152
|
-
assert.ok(!DEFAULT_EXCLUDES.includes('.vscode'), 'Should NOT include .vscode');
|
|
153
153
|
assert.ok(!DEFAULT_EXCLUDES.includes('.gitea'), 'Should NOT include .gitea');
|
|
154
154
|
assert.ok(!DEFAULT_EXCLUDES.includes('.stignore'), 'Should NOT include .stignore');
|
|
155
155
|
});
|
package/tests/learn.test.ts
CHANGED
|
@@ -38,15 +38,16 @@ function cleanConfig() {
|
|
|
38
38
|
|
|
39
39
|
/** Capture console.log output during an async callback. */
|
|
40
40
|
async function captureStdout(fn: () => Promise<void>): Promise<string> {
|
|
41
|
-
const
|
|
41
|
+
const originalWrite = process.stdout.write;
|
|
42
42
|
let captured = '';
|
|
43
|
-
|
|
44
|
-
captured +=
|
|
43
|
+
process.stdout.write = (chunk: any) => {
|
|
44
|
+
captured += chunk.toString();
|
|
45
|
+
return true;
|
|
45
46
|
};
|
|
46
47
|
try {
|
|
47
48
|
await fn();
|
|
48
49
|
} finally {
|
|
49
|
-
|
|
50
|
+
process.stdout.write = originalWrite;
|
|
50
51
|
}
|
|
51
52
|
return captured;
|
|
52
53
|
}
|