@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
|
@@ -238,13 +238,19 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
238
238
|
printNoNewFolders();
|
|
239
239
|
folders = existingTemplate.folders;
|
|
240
240
|
}
|
|
241
|
+
// selectedStructure should include ALL folders (existing + added) in additive mode
|
|
242
|
+
// so that the final filter doesn't drop user-selected new folders
|
|
243
|
+
selectedStructure = [
|
|
244
|
+
...new Set([
|
|
245
|
+
...existingTemplate.folders?.map((f) => f.name) || [],
|
|
246
|
+
...folders.filter(f => !existingTemplate.folders?.some(ef => ef.name === f.name)).map(f => f.name),
|
|
247
|
+
]),
|
|
248
|
+
];
|
|
241
249
|
// New files
|
|
242
250
|
const newFiles = rootFiles.filter((f) => !existingTemplate.copy_files?.some((cf) => cf.src === f));
|
|
243
251
|
printNewFiles(newFiles.length, newFiles);
|
|
244
252
|
const addedFiles = await promptNewFiles(newFiles, options);
|
|
245
253
|
selectedFiles = [...(existingTemplate.copy_files?.filter((cf) => !rootDirs.includes(cf.src)).map((cf) => cf.src) || []), ...addedFiles];
|
|
246
|
-
// Structure
|
|
247
|
-
selectedStructure = existingTemplate.folders?.map((f) => f.name) || [];
|
|
248
254
|
// Seed selectedFolders from existing copy_files directory entries
|
|
249
255
|
selectedFolders = (existingTemplate.copy_files || [])
|
|
250
256
|
.filter((f) => rootDirs.includes(f.src))
|
|
@@ -266,7 +272,14 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
266
272
|
}
|
|
267
273
|
// --- COPY FILES ---
|
|
268
274
|
const existingCopyFiles = isUpdate ? config.templates[updateTemplate].copy_files || [] : [];
|
|
269
|
-
|
|
275
|
+
// If JSON template config has copy_files, use those directly (for new templates)
|
|
276
|
+
let copy_files;
|
|
277
|
+
if (!isUpdate && fileTemplateConfig.copy_files && Array.isArray(fileTemplateConfig.copy_files)) {
|
|
278
|
+
copy_files = [...fileTemplateConfig.copy_files];
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
copy_files = buildCopyFiles(selectedFiles, selectedFolders, existingCopyFiles);
|
|
282
|
+
}
|
|
270
283
|
const templateConfig = {
|
|
271
284
|
description: description,
|
|
272
285
|
templateRoot: resolvedPath,
|
|
@@ -308,8 +321,18 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
308
321
|
const detectedExecutables = rootFiles
|
|
309
322
|
.filter(file => isExecutable(path.join(resolvedPath, file), file))
|
|
310
323
|
.filter(file => !shouldExcludeFile(file));
|
|
324
|
+
// In additive mode (isUpdate), only add executables that were explicitly selected by the user
|
|
325
|
+
let selectedExecutables = [];
|
|
326
|
+
if (isUpdate) {
|
|
327
|
+
// In additive mode, only include executables that are in selectedFiles (user explicitly chose them)
|
|
328
|
+
selectedExecutables = detectedExecutables.filter(exec => selectedFiles.includes(exec));
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
// In new template mode, include all detected executables (original behavior)
|
|
332
|
+
selectedExecutables = detectedExecutables;
|
|
333
|
+
}
|
|
311
334
|
const existingPostCopy = isUpdate ? config.templates[updateTemplate].post_copy || [] : [];
|
|
312
|
-
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy,
|
|
335
|
+
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy, selectedExecutables);
|
|
313
336
|
if (post_copy.length > 0) {
|
|
314
337
|
templateConfig.post_copy = post_copy;
|
|
315
338
|
const postCopySrcs = post_copy.map(f => f.src);
|
|
@@ -318,9 +341,7 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
318
341
|
// --- OUTPUT ---
|
|
319
342
|
if (options.json) {
|
|
320
343
|
const output = { name: targetName, ...templateConfig };
|
|
321
|
-
process.stdout.write(JSON.stringify(output, null, 2) + '\n'
|
|
322
|
-
process.exit(0);
|
|
323
|
-
});
|
|
344
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
324
345
|
return;
|
|
325
346
|
}
|
|
326
347
|
config.templates[targetName] = templateConfig;
|
|
@@ -153,7 +153,9 @@ export function getRootEntries(dirPath, ignorePatterns) {
|
|
|
153
153
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
154
154
|
.filter(e => !shouldExclude(dirPath, path.join(dirPath, e.name), ignorePatterns))
|
|
155
155
|
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
156
|
-
const files = entries.filter(e => e.isFile())
|
|
156
|
+
const files = entries.filter(e => e.isFile())
|
|
157
|
+
.map(e => e.name)
|
|
158
|
+
.filter(fileName => !shouldExcludeFile(fileName));
|
|
157
159
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
158
160
|
return { files, dirs };
|
|
159
161
|
}
|
|
@@ -204,12 +206,21 @@ export function mergePostConfigTasks(existingTasks, jsonTasks, detectedTasks) {
|
|
|
204
206
|
if (jsonTasks && Array.isArray(jsonTasks)) {
|
|
205
207
|
return [...jsonTasks];
|
|
206
208
|
}
|
|
207
|
-
|
|
209
|
+
// Merge detected tasks with existing, avoiding duplicates
|
|
210
|
+
const merged = [...existingTasks];
|
|
211
|
+
for (const dt of detectedTasks) {
|
|
212
|
+
const exists = merged.some(et => et.command === dt.command && et.script === dt.script);
|
|
213
|
+
if (!exists) {
|
|
214
|
+
merged.push(dt);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return merged;
|
|
208
218
|
}
|
|
209
219
|
/**
|
|
210
220
|
* Merge post_copy files from existing, JSON file, and detected executables
|
|
221
|
+
* Only adds executables that were explicitly selected by the user
|
|
211
222
|
*/
|
|
212
|
-
export function mergePostCopyFiles(existingPostCopy, jsonPostCopy,
|
|
223
|
+
export function mergePostCopyFiles(existingPostCopy, jsonPostCopy, selectedExecutables) {
|
|
213
224
|
let post_copy = [...existingPostCopy];
|
|
214
225
|
if (jsonPostCopy && Array.isArray(jsonPostCopy)) {
|
|
215
226
|
for (const pc of jsonPostCopy) {
|
|
@@ -218,10 +229,9 @@ export function mergePostCopyFiles(existingPostCopy, jsonPostCopy, detectedExecu
|
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
231
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
for (const file of newExecutables) {
|
|
232
|
+
// Only add executables that were explicitly selected by the user
|
|
233
|
+
for (const file of selectedExecutables) {
|
|
234
|
+
if (!post_copy.some(existing => existing.src === file)) {
|
|
225
235
|
post_copy.push({ src: file, dest: file });
|
|
226
236
|
}
|
|
227
237
|
}
|
|
@@ -4,7 +4,7 @@ import inquirer from 'inquirer';
|
|
|
4
4
|
import { loadConfig, saveConfig, getTemplateNames, shouldExclude, shouldIgnore, shouldExcludeFile, getDefaultPostConfig } from '../config.js';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { downloadAndExtract } from '../remote.js';
|
|
7
|
-
import { extractStructure, findVariablesInFiles, isExecutable, parseInfoFile, loadJsonTemplateConfig, parsePostConfigScript, mergePostConfigTasks, mergePostCopyFiles,
|
|
7
|
+
import { extractStructure, findVariablesInFiles, isExecutable, parseInfoFile, loadJsonTemplateConfig, parsePostConfigScript, mergePostConfigTasks, mergePostCopyFiles, promptNewVariables, promptGlobalVariables, printNoNewVariables, promptNewFolders, printNoNewFolders, printNewFiles, promptNewFiles, promptRootFiles, promptStructureFolders, promptCopyFolders, promptPostConfigTasks } from './template-utils.js';
|
|
8
8
|
export async function update(sourcePath, templateName, options = {}) {
|
|
9
9
|
const isFullMode = options.noDiff;
|
|
10
10
|
let resolvedPath;
|
|
@@ -125,11 +125,13 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
125
125
|
if (!isFullMode) {
|
|
126
126
|
// Additive mode: only present new variables for selection
|
|
127
127
|
const existingVarNames = new Set(config.templates[templateName].variables?.map(v => v.name) || []);
|
|
128
|
+
// newVars are NOT added to variables yet - only existing variables + JSON variables are in variables
|
|
128
129
|
const newVars = variables.filter(v => !existingVarNames.has(v.name));
|
|
129
130
|
if (newVars.length > 0) {
|
|
130
131
|
console.log(chalk.cyan(`\n📊 New Variables:`));
|
|
131
132
|
console.log(chalk.green(` + ${newVars.length} new variable(s): ${newVars.map(v => v.name).join(', ')}`));
|
|
132
133
|
const selectedNewVars = await promptNewVariables(newVars, options);
|
|
134
|
+
// Only add the selected new variables (not all newVars)
|
|
133
135
|
variables.push(...selectedNewVars);
|
|
134
136
|
}
|
|
135
137
|
else {
|
|
@@ -150,24 +152,34 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
else {
|
|
153
|
-
// Full mode:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
// Full mode: replace all variables with detected ones (no additive)
|
|
156
|
+
variables = [];
|
|
157
|
+
// Add detected variables
|
|
158
|
+
for (const varName of detectedVars) {
|
|
159
|
+
variables.push({
|
|
160
|
+
name: varName,
|
|
161
|
+
prompt: `Enter ${varName}:`,
|
|
162
|
+
required: true
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// Also include JSON variables
|
|
166
|
+
if (fileTemplateConfig.variables && Array.isArray(fileTemplateConfig.variables)) {
|
|
167
|
+
for (const v of fileTemplateConfig.variables) {
|
|
168
|
+
const existingIndex = variables.findIndex(existing => existing.name === v.name);
|
|
169
|
+
if (existingIndex !== -1) {
|
|
170
|
+
variables[existingIndex] = { ...variables[existingIndex], ...v };
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
variables.push({ ...v });
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
}
|
|
162
|
-
if (globalVarsToPrompt.length > 0) {
|
|
163
|
-
const selectedGlobals = await promptGlobalVariables(globalVarsToPrompt, options);
|
|
164
|
-
variables.push(...selectedGlobals);
|
|
165
|
-
}
|
|
166
|
-
const additionalVars = await promptAdditionalVariables(variables, options);
|
|
167
|
-
variables.push(...additionalVars);
|
|
168
177
|
}
|
|
169
178
|
// 1. Structure (skeleton) - Additive mode
|
|
170
179
|
let folders = [];
|
|
180
|
+
let selectedStructure = [];
|
|
181
|
+
let selectedFiles = [];
|
|
182
|
+
let selectedFolders = [];
|
|
171
183
|
if (!isFullMode) {
|
|
172
184
|
// Additive mode: only add new folders
|
|
173
185
|
const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
|
|
@@ -185,6 +197,14 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
185
197
|
printNoNewFolders();
|
|
186
198
|
folders = existingFolders;
|
|
187
199
|
}
|
|
200
|
+
// selectedStructure should include ALL folders (existing + added) in additive mode
|
|
201
|
+
// so that the final filter doesn't drop user-selected new folders
|
|
202
|
+
selectedStructure = [
|
|
203
|
+
...new Set([
|
|
204
|
+
...existingFolders.map(f => f.name),
|
|
205
|
+
...folders.filter(f => !existingFolders.some(ef => ef.name === f.name)).map(f => f.name),
|
|
206
|
+
]),
|
|
207
|
+
];
|
|
188
208
|
}
|
|
189
209
|
else {
|
|
190
210
|
// Full mode: original behavior
|
|
@@ -196,11 +216,10 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
196
216
|
const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
|
197
217
|
.filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
|
|
198
218
|
.filter(e => !shouldIgnore(e.name, e.name, ignorePatterns));
|
|
199
|
-
const rootFiles = rootEntries.filter(e => e.isFile())
|
|
219
|
+
const rootFiles = rootEntries.filter(e => e.isFile())
|
|
220
|
+
.map(e => e.name)
|
|
221
|
+
.filter(fileName => !shouldExcludeFile(fileName));
|
|
200
222
|
const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
|
|
201
|
-
let selectedFiles = [];
|
|
202
|
-
let selectedFolders = [];
|
|
203
|
-
let selectedStructure = [];
|
|
204
223
|
if (!isFullMode) {
|
|
205
224
|
// Additive mode for files and folders
|
|
206
225
|
const existingCopyFiles = config.templates[templateName].copy_files || [];
|
|
@@ -209,8 +228,6 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
209
228
|
printNewFiles(newFiles.length, newFiles);
|
|
210
229
|
const addedFiles = await promptNewFiles(newFiles, options);
|
|
211
230
|
selectedFiles = [...existingCopyFiles.filter(cf => !rootDirs.includes(cf.src)).map(cf => cf.src), ...addedFiles];
|
|
212
|
-
// Structure
|
|
213
|
-
selectedStructure = config.templates[templateName].folders?.map(f => f.name) || [];
|
|
214
231
|
// Seed selectedFolders from existing copy_files directory entries
|
|
215
232
|
selectedFolders = existingCopyFiles
|
|
216
233
|
.filter(f => rootDirs.includes(f.src))
|
|
@@ -228,9 +245,9 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
228
245
|
}
|
|
229
246
|
}
|
|
230
247
|
else {
|
|
231
|
-
// Full mode: original behavior
|
|
248
|
+
// Full mode: original behavior - pick up all files like a fresh learn
|
|
232
249
|
if (options.yes || options.json) {
|
|
233
|
-
selectedFiles = rootFiles
|
|
250
|
+
selectedFiles = rootFiles;
|
|
234
251
|
selectedStructure = rootDirs;
|
|
235
252
|
selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
|
|
236
253
|
}
|
|
@@ -308,8 +325,24 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
308
325
|
const detectedExecutables = rootFiles
|
|
309
326
|
.filter(file => isExecutable(path.join(resolvedPath, file), file))
|
|
310
327
|
.filter(file => !shouldExcludeFile(file));
|
|
328
|
+
// In additive mode, only add executables that were explicitly selected by the user
|
|
329
|
+
let selectedExecutables = [];
|
|
330
|
+
if (!isFullMode) {
|
|
331
|
+
// In additive mode, only include executables that are in selectedFiles (user explicitly chose them)
|
|
332
|
+
// BUT exclude files that already exist in copy_files with custom settings (chmod, substitute_variables: false)
|
|
333
|
+
const existingCopyFiles = config.templates[templateName].copy_files || [];
|
|
334
|
+
const existingCopySrcs = new Set(existingCopyFiles.map(cf => cf.src));
|
|
335
|
+
const existingCustomSettings = new Set(existingCopyFiles
|
|
336
|
+
.filter(cf => cf.chmod || cf.substitute_variables === false)
|
|
337
|
+
.map(cf => cf.src));
|
|
338
|
+
selectedExecutables = detectedExecutables.filter(exec => selectedFiles.includes(exec) && !existingCustomSettings.has(exec));
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// In full mode, include all detected executables (original behavior)
|
|
342
|
+
selectedExecutables = detectedExecutables;
|
|
343
|
+
}
|
|
311
344
|
const existingPostCopy = config.templates[templateName].post_copy || [];
|
|
312
|
-
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy,
|
|
345
|
+
const post_copy = mergePostCopyFiles(existingPostCopy, fileTemplateConfig.post_copy, selectedExecutables);
|
|
313
346
|
if (post_copy.length > 0) {
|
|
314
347
|
templateConfig.post_copy = post_copy;
|
|
315
348
|
const postCopySrcs = post_copy.map(f => f.src);
|
|
@@ -320,9 +353,7 @@ export async function update(sourcePath, templateName, options = {}) {
|
|
|
320
353
|
name: templateName,
|
|
321
354
|
...templateConfig
|
|
322
355
|
};
|
|
323
|
-
process.stdout.write(JSON.stringify(output, null, 2) + '\n'
|
|
324
|
-
process.exit(0);
|
|
325
|
-
});
|
|
356
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
326
357
|
return;
|
|
327
358
|
}
|
|
328
359
|
config.templates[templateName] = templateConfig;
|
package/dist/config.js
CHANGED
|
@@ -223,6 +223,7 @@ export const DEFAULT_EXCLUDES = [
|
|
|
223
223
|
'dist',
|
|
224
224
|
'build',
|
|
225
225
|
'bin',
|
|
226
|
+
'.vscode',
|
|
226
227
|
'.DS_Store',
|
|
227
228
|
'Thumbs.db',
|
|
228
229
|
];
|
|
@@ -327,6 +328,8 @@ export function shouldExcludeFile(fileName) {
|
|
|
327
328
|
'yarn.lock',
|
|
328
329
|
'pnpm-lock.yaml',
|
|
329
330
|
'composer.lock',
|
|
331
|
+
'post_config.sh',
|
|
332
|
+
'post_config.bat',
|
|
330
333
|
];
|
|
331
334
|
for (const pattern of excludePatterns) {
|
|
332
335
|
if (pattern.startsWith('*')) {
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ const program = new Command();
|
|
|
18
18
|
program
|
|
19
19
|
.name('pt')
|
|
20
20
|
.description('Project Template CLI - Learn project structures and initialize new ones')
|
|
21
|
-
.version(pkg.version, '-v', 'output the version number');
|
|
21
|
+
.version(pkg.version, '-v, --version', 'output the version number');
|
|
22
22
|
program
|
|
23
23
|
.command('learn [path]')
|
|
24
24
|
.description('Learn a project structure from an existing directory')
|
|
@@ -65,15 +65,33 @@ program
|
|
|
65
65
|
}
|
|
66
66
|
});
|
|
67
67
|
program
|
|
68
|
-
.command('init [
|
|
69
|
-
.description('Initialize a new project from
|
|
68
|
+
.command('init [args...]')
|
|
69
|
+
.description('Initialize a new project from one or more learned templates')
|
|
70
70
|
.option('-f, --file <jsonPath>', 'Initialize directly from a JSON template file without adding it to local config')
|
|
71
71
|
.option('--skip-post-config', 'Skip running post-config tasks')
|
|
72
72
|
.option('--dry-run', 'Show what would be created without making changes')
|
|
73
73
|
.option('-y, --yes', 'Automatically answer yes to prompts')
|
|
74
74
|
.option('--vars <variables>', 'Comma-separated key=value variables (e.g. key1=val1,key2=val2)')
|
|
75
|
-
.
|
|
76
|
-
|
|
75
|
+
.option('--collision <mode>', 'File collision resolution strategy (overwrite, newest)', 'overwrite')
|
|
76
|
+
.option('--json', 'Output result as JSON')
|
|
77
|
+
.action(async (args, options) => {
|
|
78
|
+
try {
|
|
79
|
+
await init(args, options);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (options.json) {
|
|
83
|
+
process.stdout.write(JSON.stringify({
|
|
84
|
+
status: 'error',
|
|
85
|
+
message: err.message || String(err)
|
|
86
|
+
}) + '\n', () => {
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
77
95
|
});
|
|
78
96
|
program
|
|
79
97
|
.command('config [templateName]')
|
package/dist/substitute.js
CHANGED
|
@@ -34,44 +34,62 @@ export function substituteVariables(content, variables, maxIterations = 10) {
|
|
|
34
34
|
/**
|
|
35
35
|
* Processes copy_files tasks from a template.
|
|
36
36
|
*/
|
|
37
|
-
export async function processCopyFiles(templateRoot, resolvedDest, template, variables, dryRun = false) {
|
|
37
|
+
export async function processCopyFiles(templateRoot, resolvedDest, template, variables, dryRun = false, collisionMode = 'overwrite', silent = false) {
|
|
38
38
|
if (!template.copy_files)
|
|
39
39
|
return;
|
|
40
40
|
for (const copyFile of template.copy_files) {
|
|
41
41
|
const srcPath = path.join(templateRoot, copyFile.src);
|
|
42
42
|
const destPath = path.join(resolvedDest, sanitizePath(copyFile.dest));
|
|
43
43
|
if (!fs.existsSync(srcPath)) {
|
|
44
|
-
|
|
44
|
+
if (!silent)
|
|
45
|
+
console.warn(chalk.yellow(`Warning: ${copyFile.src} not found in template`));
|
|
45
46
|
continue;
|
|
46
47
|
}
|
|
47
48
|
const stat = fs.statSync(srcPath);
|
|
48
49
|
if (stat.isDirectory()) {
|
|
49
50
|
// Recursive directory copy
|
|
50
51
|
if (dryRun) {
|
|
51
|
-
|
|
52
|
+
if (!silent)
|
|
53
|
+
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
52
54
|
}
|
|
53
55
|
else {
|
|
54
56
|
const dirSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
55
57
|
template.variables &&
|
|
56
58
|
template.variables.length > 0 &&
|
|
57
59
|
Object.keys(variables).length > 0));
|
|
58
|
-
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
60
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod, collisionMode, silent);
|
|
59
61
|
}
|
|
60
|
-
|
|
62
|
+
if (!silent)
|
|
63
|
+
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
61
64
|
}
|
|
62
65
|
else {
|
|
66
|
+
// Check collision mode
|
|
67
|
+
if (collisionMode === 'newest' && fs.existsSync(destPath)) {
|
|
68
|
+
const destStat = fs.statSync(destPath);
|
|
69
|
+
if (destStat.mtimeMs > stat.mtimeMs) {
|
|
70
|
+
if (dryRun && !silent) {
|
|
71
|
+
console.log(chalk.yellow(` [DRY RUN] [COLLISION] Destination is newer, keeping ${copyFile.dest}`));
|
|
72
|
+
}
|
|
73
|
+
else if (!silent) {
|
|
74
|
+
console.log(chalk.yellow(` [COLLISION] Destination is newer, keeping ${copyFile.dest}`));
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
63
79
|
// Single file copy
|
|
64
80
|
if (dryRun) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
if (!silent) {
|
|
82
|
+
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
83
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
84
|
+
template.variables &&
|
|
85
|
+
template.variables.length > 0 &&
|
|
86
|
+
Object.keys(variables).length > 0));
|
|
87
|
+
if (drySubstitute) {
|
|
88
|
+
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
89
|
+
}
|
|
90
|
+
if (copyFile.chmod) {
|
|
91
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod ${copyFile.chmod} ${copyFile.dest}`));
|
|
92
|
+
}
|
|
75
93
|
}
|
|
76
94
|
continue;
|
|
77
95
|
}
|
|
@@ -93,25 +111,33 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
93
111
|
fs.chmodSync(destPath, parseInt(copyFile.chmod, 8));
|
|
94
112
|
}
|
|
95
113
|
catch (e) {
|
|
96
|
-
if (process.platform !== 'win32') {
|
|
114
|
+
if (process.platform !== 'win32' && !silent) {
|
|
97
115
|
console.error(chalk.red(`Failed to set chmod ${copyFile.chmod} on ${copyFile.dest}`));
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
}
|
|
101
|
-
|
|
119
|
+
if (!silent)
|
|
120
|
+
console.log(chalk.green(` ✓ ${copyFile.dest}`));
|
|
102
121
|
}
|
|
103
122
|
}
|
|
104
123
|
}
|
|
105
|
-
function copyDirRecursive(src, dest, variables, substitute, chmod) {
|
|
124
|
+
function copyDirRecursive(src, dest, variables, substitute, chmod, collisionMode = 'overwrite', silent = false) {
|
|
106
125
|
fs.mkdirSync(dest, { recursive: true });
|
|
107
126
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
108
127
|
for (const entry of entries) {
|
|
109
128
|
const srcPath = path.join(src, entry.name);
|
|
110
129
|
const destPath = path.join(dest, entry.name);
|
|
111
130
|
if (entry.isDirectory()) {
|
|
112
|
-
copyDirRecursive(srcPath, destPath, variables, substitute, chmod);
|
|
131
|
+
copyDirRecursive(srcPath, destPath, variables, substitute, chmod, collisionMode, silent);
|
|
113
132
|
}
|
|
114
133
|
else {
|
|
134
|
+
if (collisionMode === 'newest' && fs.existsSync(destPath)) {
|
|
135
|
+
const destStat = fs.statSync(destPath);
|
|
136
|
+
const srcStat = fs.statSync(srcPath);
|
|
137
|
+
if (destStat.mtimeMs > srcStat.mtimeMs) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
115
141
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
116
142
|
if (substitute) {
|
|
117
143
|
content = substituteVariables(content, variables);
|
package/doc/configuration.md
CHANGED
|
@@ -161,14 +161,16 @@ Each task supports:
|
|
|
161
161
|
| `script` | Path to script relative to template root |
|
|
162
162
|
| `cross_platform` | If `true`, use platform-safe runner |
|
|
163
163
|
|
|
164
|
-
**Interaction flow** during `pt init
|
|
164
|
+
**Interaction flow** during `pt init` (single or multiple templates):
|
|
165
165
|
|
|
166
166
|
1. Folder structure created
|
|
167
|
-
2. If template
|
|
167
|
+
2. If template(s) have `post_config`:
|
|
168
168
|
- Filter tasks by project type
|
|
169
|
-
-
|
|
170
|
-
-
|
|
171
|
-
-
|
|
169
|
+
- **Aggregate security check** — all templates' warnings shown in one prompt
|
|
170
|
+
- If warnings exist: prompt `Security warnings found in N template(s). Run post-config tasks anyway? (y/N)`
|
|
171
|
+
- Show unified task list with checkboxes (duplicates merged, template attribution shown)
|
|
172
|
+
- Prompt: `Select post-config tasks to run:`
|
|
173
|
+
- Run selected tasks, show ✓/✗ per task
|
|
172
174
|
3. If no `post_config`, suggest baked-in defaults:
|
|
173
175
|
- Prompt: `No post-config defined. Use suggested tasks?`
|
|
174
176
|
4. If `--skip-post-config` flag: skip entirely
|
package/doc/usage.md
CHANGED
|
@@ -124,22 +124,39 @@ project=MyProject
|
|
|
124
124
|
## Initialize a project
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
|
-
# Initialize from
|
|
128
|
-
pt init <template_name> /path/to/new/PROJECT
|
|
127
|
+
# Initialize from one or more templates (auto-suggests post-config tasks)
|
|
128
|
+
pt init <template_name> [template_name2...] /path/to/new/PROJECT
|
|
129
129
|
|
|
130
130
|
# Skip post-config tasks
|
|
131
|
-
pt init <template_name> /path/to/new/PROJECT --skip-post-config
|
|
131
|
+
pt init <template_name> [template_name2...] /path/to/new/PROJECT --skip-post-config
|
|
132
132
|
|
|
133
133
|
# Dry run (preview actions without execution)
|
|
134
|
-
pt init <template_name> /path/to/new/PROJECT --dry-run
|
|
134
|
+
pt init <template_name> [template_name2...] /path/to/new/PROJECT --dry-run
|
|
135
135
|
|
|
136
136
|
# Non-interactive mode with variables (useful for an API or AI agents)
|
|
137
|
-
pt init <template_name> /path/to/new/PROJECT --yes --vars project_name=foo,author=bar
|
|
137
|
+
pt init <template_name> [template_name2...] /path/to/new/PROJECT --yes --vars project_name=foo,author=bar
|
|
138
138
|
|
|
139
139
|
# Initialize directly from a JSON template file (no config.yaml registration)
|
|
140
140
|
pt init /path/to/new/PROJECT --file my-template.json --yes
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### Multi-Template Initialization
|
|
144
|
+
|
|
145
|
+
When you provide multiple template names, `pt` combines them into a single project:
|
|
146
|
+
|
|
147
|
+
- **Folder structures merged** — duplicate folder names are merged recursively
|
|
148
|
+
- **Variables merged** — duplicate variable names use the last template's defaults
|
|
149
|
+
- **Copy files merged** — files with same destination prompt for collision resolution
|
|
150
|
+
- **Post-config tasks deduplicated** — identical tasks (same command + description) run once, with template attribution shown in selection
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# Combine base template with addon
|
|
154
|
+
pt init base-template caddy-addon /path/to/new/PROJECT
|
|
155
|
+
|
|
156
|
+
# Interactive: you'll see one security prompt for all templates, then a unified task list
|
|
157
|
+
# --yes: all deduplicated tasks run automatically
|
|
158
|
+
```
|
|
159
|
+
|
|
143
160
|
### Direct JSON Scaffolding (`--file`)
|
|
144
161
|
|
|
145
162
|
The `--file` option allows you to scaffold a project directly from a JSON template file **without** registering it in your local `~/.pt/config.yaml`. This is ideal for:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@garyr/pt-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Project Template CLI - Learn structures and initialize projects",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"dev": "tsx src/index.ts",
|
|
14
14
|
"test": "node --import tsx --test tests/**/*.test.ts",
|
|
15
15
|
"test:sequential": "node --import tsx --test tests/config.test.ts && node --import tsx --test tests/init.test.ts && node --import tsx --test tests/learn.test.ts && node --import tsx --test tests/substitute.test.ts && node --import tsx --test tests/config-utils.test.ts",
|
|
16
|
-
"completion:bash": "tsx src/
|
|
17
|
-
"completion:zsh": "tsx src/
|
|
18
|
-
"completion:fish": "tsx src/
|
|
16
|
+
"completion:bash": "tsx src/index.ts completion bash",
|
|
17
|
+
"completion:zsh": "tsx src/index.ts completion zsh",
|
|
18
|
+
"completion:fish": "tsx src/index.ts completion fish",
|
|
19
19
|
"prepublishOnly": "npm run build",
|
|
20
20
|
"build:linux": "bun build ./src/index.ts --compile --minify --target=bun-linux-x64 --outfile ../BUILD/pt-cli/pt-linux",
|
|
21
21
|
"build:macos": "bun build ./src/index.ts --compile --minify --target=bun-darwin-x64 --outfile ../BUILD/pt-cli/pt-macos",
|
|
@@ -15,13 +15,14 @@ As an agent equipped with this skill, you have the ability to rapidly scaffold,
|
|
|
15
15
|
|
|
16
16
|
2. **Scaffolding (`pt init`):**
|
|
17
17
|
When a matching template exists, initialize it using the non-interactive flags. URL targets (GitHub, Gitea, etc.) are automatically translated to tarball downloads.
|
|
18
|
-
- **Command:** `pt init <template_name> <destination_path> --yes`
|
|
19
|
-
-
|
|
18
|
+
- **Command:** `pt init <template_name> [template_name2...] <destination_path> --yes`
|
|
19
|
+
- Multiple templates can be combined: `pt init base-template caddy-addon /path/to/new/PROJECT --yes`
|
|
20
|
+
- If templates require variables, pass them: `pt init <template_name> [template_name2...] <destination_path> --yes --vars key1=value1,key2=value2`
|
|
20
21
|
- **Direct JSON scaffolding:** To scaffold from a JSON template file without registering it in `config.yaml`:
|
|
21
22
|
`pt init <destination_path> --file <json_path> --yes`
|
|
22
23
|
- *Never* run `pt init` without `--yes`, as interactive prompts will block you.
|
|
23
|
-
- **Dry-run:** Preview what would be created without making changes: `pt init <template_name> <destination_path> --yes --dry-run`
|
|
24
|
-
- **Skip post-config:** Skip running post-config tasks: `pt init <template_name> <destination_path> --yes --skip-post-config`
|
|
24
|
+
- **Dry-run:** Preview what would be created without making changes: `pt init <template_name> [template_name2...] <destination_path> --yes --dry-run`
|
|
25
|
+
- **Skip post-config:** Skip running post-config tasks: `pt init <template_name> [template_name2...] <destination_path> --yes --skip-post-config`
|
|
25
26
|
- Note any errors from auto-executed post-config tasks (like `npm install` failing) and correct them if necessary.
|
|
26
27
|
|
|
27
28
|
3. **Capturing Knowledge (`pt learn`):**
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
2
|
import YAML from 'yaml';
|
|
5
3
|
import { getConfigPath } from '../config.js';
|
|
6
4
|
|
|
@@ -68,8 +66,8 @@ _pt_completions() {
|
|
|
68
66
|
;;
|
|
69
67
|
init)
|
|
70
68
|
if [[ "$cur" == -* ]]; then
|
|
71
|
-
COMPREPLY=( $(compgen -W "-f --file --skip-post-config --dry-run -y --yes --vars -h --help" -- "$cur") )
|
|
72
|
-
elif [[ $cword -
|
|
69
|
+
COMPREPLY=( $(compgen -W "-f --file --skip-post-config --dry-run -y --yes --vars --collision --json -h --help" -- "$cur") )
|
|
70
|
+
elif [[ $cword -ge 2 ]]; then
|
|
73
71
|
local templates
|
|
74
72
|
templates=$(pt completion --templates 2>/dev/null)
|
|
75
73
|
COMPREPLY=( $(compgen -W "$templates" -- "$cur") )
|
|
@@ -202,9 +200,10 @@ _pt() {
|
|
|
202
200
|
'--dry-run[Show what would be created without making changes]' \\
|
|
203
201
|
'(-y --yes)'{-y,--yes}'[Automatically answer yes to prompts]' \\
|
|
204
202
|
'--vars=[Comma-separated key=value variables]:variables:' \\
|
|
203
|
+
'--collision=[File collision resolution strategy]:mode:(overwrite newest)' \\
|
|
204
|
+
'--json[Output result as JSON]' \\
|
|
205
205
|
'(-h --help)'{-h,--help}'[display help for command]' \\
|
|
206
|
-
'
|
|
207
|
-
'2:destPath:_files -/'
|
|
206
|
+
'*:templates:_pt_templates'
|
|
208
207
|
;;
|
|
209
208
|
config)
|
|
210
209
|
_arguments \\
|
|
@@ -331,6 +330,8 @@ complete -c pt -n '__fish_pt_using_command init' -l skip-post-config -d 'Skip ru
|
|
|
331
330
|
complete -c pt -n '__fish_pt_using_command init' -l dry-run -d 'Show what would be created without making changes'
|
|
332
331
|
complete -c pt -n '__fish_pt_using_command init' -s y -l yes -d 'Automatically answer yes to prompts'
|
|
333
332
|
complete -c pt -n '__fish_pt_using_command init' -l vars -d 'Comma-separated key=value variables'
|
|
333
|
+
complete -c pt -n '__fish_pt_using_command init' -l collision -a 'overwrite newest' -d 'File collision resolution strategy'
|
|
334
|
+
complete -c pt -n '__fish_pt_using_command init' -l json -d 'Output result as JSON'
|
|
334
335
|
|
|
335
336
|
# config
|
|
336
337
|
complete -c pt -n '__fish_pt_using_command config' -a '(__fish_pt_templates)' -d 'Template name'
|
|
@@ -404,12 +405,3 @@ export async function completionCommand(shellArg?: string, options?: { templates
|
|
|
404
405
|
}
|
|
405
406
|
}
|
|
406
407
|
|
|
407
|
-
// Allow direct execution via tsx src/commands/completionCommand.ts <shell>
|
|
408
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
409
|
-
const shell = process.argv[2];
|
|
410
|
-
if (shell === '--templates' || shell === '_templates') {
|
|
411
|
-
completionCommand(shell, { templates: true });
|
|
412
|
-
} else {
|
|
413
|
-
completionCommand(shell);
|
|
414
|
-
}
|
|
415
|
-
}
|