@garyr/pt-cli 1.1.0 → 1.1.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/README.md CHANGED
@@ -32,21 +32,27 @@ graph LR
32
32
  <!-- TOC -->
33
33
 
34
34
  - [pt - Project Template CLI](#pt---project-template-cli)
35
- - [Why pt-cli?](#why-pt-cli)
36
- - [Core Benefits & Uses](#core-benefits--uses)
37
- - [🚀 Low-Friction Templating](#-low-friction-templating)
38
- - [🧠 Reduces Cognitive Load](#-reduces-cognitive-load)
39
- - [📦 Sharing is Caring](#-sharing-is-caring)
40
- - [🤖 Agentic and API Friendly](#-agentic-and-api-friendly)
41
- - [Features at a Glance](#features-at-a-glance)
42
- - [Quick Start](#quick-start)
43
- - [Installation](#installation)
44
- - [Basic Commands](#basic-commands)
45
- - [Shell Completions](#shell-completions)
46
- - [Agent Integration](#agent-integration)
47
- - [Documentation](#documentation)
48
- - [Development](#development)
49
- - [Where are the Templates?](#where-are-the-templates)
35
+ - [Why pt-cli?](#why-pt-cli)
36
+ - [Core Benefits \& Uses](#core-benefits--uses)
37
+ - [🚀 Low-Friction Templating](#-low-friction-templating)
38
+ - [🧠 Reduces Cognitive Load](#-reduces-cognitive-load)
39
+ - [📦 Sharing is Caring](#-sharing-is-caring)
40
+ - [🤖 Agentic and API Friendly](#-agentic-and-api-friendly)
41
+ - [Features at a Glance](#features-at-a-glance)
42
+ - [Quick Start](#quick-start)
43
+ - [Installation](#installation)
44
+ - [Basic Commands](#basic-commands)
45
+ - [Shell Completions](#shell-completions)
46
+ - [Agent Integration](#agent-integration)
47
+ - [Documentation](#documentation)
48
+ - [Development](#development)
49
+ - [Where are the Templates?](#where-are-the-templates)
50
+ - [1.0 Release \& API Stability](#10-release--api-stability)
51
+ - [🔒 Stability Guarantee (1.x series)](#-stability-guarantee-1x-series)
52
+ - [📦 Versioning Policy](#-versioning-policy)
53
+ - [📋 What's Locked in 1.0](#-whats-locked-in-10)
54
+ - [📖 Migration from 0.x to 1.0](#-migration-from-0x-to-10)
55
+ - [Documentation](#documentation-1)
50
56
 
51
57
  <!-- /TOC -->
52
58
 
@@ -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
  export function getTemplatesForCompletion() {
@@ -396,13 +394,3 @@ export async function completionCommand(shellArg, options) {
396
394
  process.exit(1);
397
395
  }
398
396
  }
399
- // Allow direct execution via tsx src/commands/completionCommand.ts <shell>
400
- if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
401
- const shell = process.argv[2];
402
- if (shell === '--templates' || shell === '_templates') {
403
- completionCommand(shell, { templates: true });
404
- }
405
- else {
406
- completionCommand(shell);
407
- }
408
- }
@@ -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
- const copy_files = buildCopyFiles(selectedFiles, selectedFolders, existingCopyFiles);
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, detectedExecutables);
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()).map(e => e.name);
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
- return detectedTasks.length > 0 ? detectedTasks : existingTasks;
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, detectedExecutables) {
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
- const newExecutables = detectedExecutables.filter(file => !post_copy.some(existing => existing.src === file));
222
- if (newExecutables.length > 0) {
223
- // In interactive mode, we'd prompt to add these - for now just auto-add
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, promptAdditionalVariables, promptNewVariables, promptGlobalVariables, printNoNewVariables, promptNewFolders, printNoNewFolders, printNewFiles, promptNewFiles, promptRootFiles, promptStructureFolders, promptCopyFolders, promptPostConfigTasks } from './template-utils.js';
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: original behavior with optional default/global variables prompt
154
- const globalVarsToPrompt = [];
155
- if (config.variables && Array.isArray(config.variables)) {
156
- for (const v of config.variables) {
157
- if (!variables.some(existing => existing.name === v.name)) {
158
- globalVarsToPrompt.push({ ...v });
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()).map(e => e.name);
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.filter(f => ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase()));
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, detectedExecutables);
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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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/commands/completionCommand.ts bash",
17
- "completion:zsh": "tsx src/commands/completionCommand.ts zsh",
18
- "completion:fish": "tsx src/commands/completionCommand.ts fish",
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",
@@ -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
 
@@ -404,12 +402,3 @@ export async function completionCommand(shellArg?: string, options?: { templates
404
402
  }
405
403
  }
406
404
 
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
- }
@@ -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
- const copy_files = buildCopyFiles(selectedFiles, selectedFolders, existingCopyFiles);
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, detectedExecutables);
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()).map(e => e.name);
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
- return detectedTasks.length > 0 ? detectedTasks : existingTasks;
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
- detectedExecutables: string[]
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
- const newExecutables = detectedExecutables.filter(file => !post_copy.some(existing => existing.src === file));
232
-
233
- if (newExecutables.length > 0) {
234
- // In interactive mode, we'd prompt to add these - for now just auto-add
235
- for (const file of newExecutables) {
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: original behavior with optional default/global variables prompt
201
- const globalVarsToPrompt: TemplateVariable[] = [];
202
- if (config.variables && Array.isArray(config.variables)) {
203
- for (const v of config.variables) {
204
- if (!variables.some(existing => existing.name === v.name)) {
205
- globalVarsToPrompt.push({ ...v });
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
- let folders: FolderNode[] = [];
221
- if (!isFullMode) {
222
- // Additive mode: only add new folders
223
- const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
224
- ? fileTemplateConfig.folders
225
- : extractStructure(resolvedPath, resolvedPath, ignorePatterns);
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
- const existingFolders = config.templates[templateName].folders || [];
228
- const newFolders = detectedFolders.filter(f => !existingFolders.some(ef => ef.name === f.name));
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
- // 2. Content Selection (Root only)
248
- const rootEntries = fs.readdirSync(resolvedPath, { withFileTypes: true })
249
- .filter(e => !shouldExclude(resolvedPath, path.join(resolvedPath, e.name), ignorePatterns))
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
- const rootFiles = rootEntries.filter(e => e.isFile()).map(e => e.name);
253
- const rootDirs = rootEntries.filter(e => e.isDirectory()).map(e => e.name);
244
+ const addedFolders = await promptNewFolders(newFolders, options);
245
+ folders = [...existingFolders, ...addedFolders];
246
+ } else {
247
+ printNoNewFolders();
248
+ folders = existingFolders;
249
+ }
254
250
 
255
- let selectedFiles: string[] = [];
256
- let selectedFolders: string[] = [];
257
- let selectedStructure: string[] = [];
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
- if (!isFullMode) {
260
- // Additive mode for files and folders
261
- const existingCopyFiles = config.templates[templateName].copy_files || [];
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
- // New files
264
- const newFiles = rootFiles.filter(f => !existingCopyFiles.some(cf => cf.src === f));
265
- printNewFiles(newFiles.length, newFiles);
266
- const addedFiles = await promptNewFiles(newFiles, options);
267
- selectedFiles = [...existingCopyFiles.filter(cf => !rootDirs.includes(cf.src)).map(cf => cf.src), ...addedFiles];
268
-
269
- // Structure
270
- selectedStructure = config.templates[templateName].folders?.map(f => f.name) || [];
271
- // Seed selectedFolders from existing copy_files directory entries
272
- selectedFolders = existingCopyFiles
273
- .filter(f => rootDirs.includes(f.src))
274
- .map(f => f.src);
275
-
276
- if (rootDirs.length > 0) {
277
- const detectedFolders = fileTemplateConfig.folders && Array.isArray(fileTemplateConfig.folders)
278
- ? fileTemplateConfig.folders
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
- const addedDirs = newFolders
283
- .filter(f => ['APP', 'scripts', 'bin'].some(p => f.name === p))
284
- .map(f => f.name);
285
- selectedStructure = [...new Set([...selectedStructure, ...addedDirs])];
286
- selectedFolders = [...new Set([...selectedFolders, ...addedDirs])];
287
- }
288
- } else {
289
- // Full mode: original behavior
290
- if (options.yes || options.json) {
291
- selectedFiles = rootFiles.filter(f => ['.makerc', 'readme.md', 'README.md', '.gitattributes', '.gitignore', 'Makefile', 'makefile', 'package.json'].some(p => f.toLowerCase() === p.toLowerCase()));
292
- selectedStructure = rootDirs;
293
- selectedFolders = rootDirs.filter(d => ['APP', 'scripts', 'bin'].some(p => d === p));
294
- } else {
295
- selectedFiles = await promptRootFiles(rootFiles, undefined, options);
296
- selectedStructure = await promptStructureFolders(rootDirs, options);
297
- selectedFolders = await promptCopyFolders(selectedStructure, undefined, options);
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, detectedExecutables);
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]')
@@ -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
  });
@@ -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 original = console.log;
41
+ const originalWrite = process.stdout.write;
42
42
  let captured = '';
43
- console.log = (...args: unknown[]) => {
44
- captured += args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
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
- console.log = original;
50
+ process.stdout.write = originalWrite;
50
51
  }
51
52
  return captured;
52
53
  }
@@ -366,6 +366,51 @@ test('update: handles post_copy executables additively', async () => {
366
366
  }
367
367
  });
368
368
 
369
+ test('update: does not add post_config.sh/.bat to post_copy when unchecked', async () => {
370
+ const srcDir = createSourceDir('.test-update-postconfig-exclude');
371
+ cleanup(testHome);
372
+
373
+ try {
374
+ const initialConfig: PtConfig = {
375
+ version: '3.0',
376
+ templates: {
377
+ 'update-test-pc-exclude': {
378
+ description: 'Test',
379
+ templateRoot: '/old/path',
380
+ folders: [],
381
+ copy_files: [],
382
+ variables: [],
383
+ post_copy: []
384
+ }
385
+ }
386
+ };
387
+ saveConfig(initialConfig);
388
+
389
+ // Create post_config scripts - these should be excluded by shouldExcludeFile
390
+ fs.writeFileSync(path.join(srcDir, 'post_config.sh'), '#!/bin/bash\necho "Running: Build"\nnpm run build\n');
391
+ fs.writeFileSync(path.join(srcDir, 'post_config.bat'), '@echo off\necho Running: Build\nnpm run build\n');
392
+ fs.chmodSync(path.join(srcDir, 'post_config.sh'), 0o755);
393
+ fs.chmodSync(path.join(srcDir, 'post_config.bat'), 0o755);
394
+
395
+ await update(srcDir, 'update-test-pc-exclude', { yes: true });
396
+
397
+ const config = loadConfig();
398
+ const tpl = config.templates['update-test-pc-exclude'];
399
+
400
+ // post_config.sh and post_config.bat should NOT be in post_copy
401
+ const postCopySrcs = (tpl.post_copy || []).map(pc => pc.src);
402
+ assert.ok(!postCopySrcs.includes('post_config.sh'), 'post_config.sh should not be in post_copy');
403
+ assert.ok(!postCopySrcs.includes('post_config.bat'), 'post_config.bat should not be in post_copy');
404
+
405
+ // They should also not be in copy_files
406
+ const copySrcs = (tpl.copy_files || []).map(cf => cf.src);
407
+ assert.ok(!copySrcs.includes('post_config.sh'), 'post_config.sh should not be in copy_files');
408
+ assert.ok(!copySrcs.includes('post_config.bat'), 'post_config.bat should not be in copy_files');
409
+ } finally {
410
+ cleanup(srcDir, testHome);
411
+ }
412
+ });
413
+
369
414
  test('update: JSON mode outputs template config', async () => {
370
415
  const srcDir = createSourceDir('.test-update-json');
371
416
  cleanup(testHome);
@@ -390,21 +435,30 @@ test('update: JSON mode outputs template config', async () => {
390
435
  fs.writeFileSync(path.join(srcDir, 'template.txt'), '{{ var }}');
391
436
 
392
437
  // Capture stdout using a proper approach
393
- const output: string[] = [];
394
- const originalLog = console.log;
395
- console.log = (...args: any[]) => {
396
- output.push(args.join(' '));
438
+ const originalWrite = process.stdout.write;
439
+ let stdoutOutput = '';
440
+ process.stdout.write = (chunk: any) => {
441
+ stdoutOutput += chunk.toString();
442
+ return true;
397
443
  };
398
-
444
+
399
445
  try {
400
446
  await update(srcDir, 'update-test-json', { yes: true, json: true });
401
447
  } finally {
402
- console.log = originalLog;
448
+ process.stdout.write = originalWrite;
403
449
  }
404
450
 
405
- // Find the JSON line (last line that starts with {)
406
- const jsonLine = output.reverse().find(line => line.trim().startsWith('{'));
407
- const parsed = JSON.parse(jsonLine!);
451
+ // Debug output
452
+ // console.error('stdoutOutput:', JSON.stringify(stdoutOutput));
453
+
454
+ // Find the JSON (starts with { and goes to end)
455
+ const firstBrace = stdoutOutput.indexOf('{');
456
+ if (firstBrace === -1) {
457
+ console.error('No JSON found. Full output:', stdoutOutput);
458
+ assert.fail('No JSON output found');
459
+ }
460
+ const jsonString = stdoutOutput.substring(firstBrace);
461
+ const parsed = JSON.parse(jsonString);
408
462
  assert.strictEqual(parsed.name, 'update-test-json');
409
463
  assert.ok(parsed.folders);
410
464
  assert.ok(parsed.copy_files);