@garyr/pt-cli 0.33.0 → 0.38.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/src/safety.ts CHANGED
@@ -13,8 +13,8 @@ const BLOCKED_COMMANDS = [
13
13
  'sudo', 'su', 'su -', 'su root',
14
14
  // Disk operations that could destroy data
15
15
  'dd', 'mkfs', 'fdisk', 'mount', 'umount',
16
- // Shell injection patterns
17
- ';', '|', '&', '&&', '||',
16
+ // Shell injection patterns - removed from blocklist to allow command chaining.
17
+ // These are validated as warnings instead.
18
18
  // Dangerous chmod
19
19
  'chmod 777', 'chmod -R 777', 'chmod 666',
20
20
  // System commands that could kill processes
@@ -28,8 +28,6 @@ const BLOCKED_COMMANDS = [
28
28
  // === DANGEROUS PATTERNS ===
29
29
  // Commands that should trigger a warning but are NOT blocked
30
30
  const DANGEROUS_PATTERNS = [
31
- // Destructive file operations
32
- 'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
33
31
  // Remote downloads + execution
34
32
  'curl', 'wget', 'wget -O', 'curl |', 'wget |',
35
33
  // Script execution
@@ -83,6 +81,31 @@ export function isBlockedCommand(command: string): boolean {
83
81
  * Check if a command should trigger a warning (but is allowed)
84
82
  */
85
83
  export function isDangerousCommand(command: string): boolean {
84
+ // Check for destructive file operations targeting absolute paths
85
+ // Regex matches 'rm', 'rmdir', or Windows 'del' followed by optional flags and then an absolute path.
86
+ // Absolute path matches:
87
+ // - Unix: starting with '/' (e.g. /tmp, /usr)
88
+ // - Windows: starting with drive letter (e.g. C:\) or UNC path (\\) or drive-relative '\'
89
+ const parts = command.split(/\s+/);
90
+ const rmIndex = parts.findIndex(p => p === 'rm' || p === 'rmdir' || p === 'del');
91
+ if (rmIndex !== -1) {
92
+ // Check subsequent arguments
93
+ for (let i = rmIndex + 1; i < parts.length; i++) {
94
+ const arg = parts[i];
95
+ if (!arg) continue;
96
+ // Skip flags (starting with - or /flag on Windows)
97
+ if (arg.startsWith('-') || (process.platform === 'win32' && arg.startsWith('/'))) {
98
+ continue;
99
+ }
100
+ // Check absolute path patterns
101
+ const isAbsolute = arg.startsWith('/') ||
102
+ (process.platform === 'win32' && (/^[a-zA-Z]:\\/.test(arg) || arg.startsWith('\\\\') || arg.startsWith('\\')));
103
+ if (isAbsolute) {
104
+ return true;
105
+ }
106
+ }
107
+ }
108
+
86
109
  for (const pattern of DANGEROUS_PATTERNS) {
87
110
  if (command.includes(pattern)) {
88
111
  return true;
@@ -164,35 +187,35 @@ export function isTrustedSource(url: string, trustedSources: string[] = DEFAULT_
164
187
  }
165
188
 
166
189
  /**
167
- * Check if a command has been executed too many times (rate limiting)
190
+ * Rate limiting: track the last execution timestamp per command hash
168
191
  */
169
- const executionCounts = new Map<string, number>();
192
+ const lastExecutionTimes = new Map<string, number>();
170
193
  const RATE_LIMIT_MS = 1000; // 1 second between identical commands
171
194
 
172
195
  export function canExecute(command: string, maxCommandsPerRun: number = 50): boolean {
173
- // Check total count per run
174
- const totalExecuted = Array.from(executionCounts.values()).reduce((sum, count) => sum + count, 0);
196
+ // Check total commands executed this run
197
+ const totalExecuted = lastExecutionTimes.size;
175
198
  if (totalExecuted >= maxCommandsPerRun) {
176
199
  return false;
177
200
  }
178
201
 
179
202
  // Check rate limit for this specific command
180
203
  const hash = crypto.createHash('md5').update(command).digest('hex');
181
- const last = executionCounts.get(hash) || 0;
182
- if (Date.now() - last < RATE_LIMIT_MS) {
204
+ const lastTime = lastExecutionTimes.get(hash) || 0;
205
+ if (Date.now() - lastTime < RATE_LIMIT_MS) {
183
206
  return false;
184
207
  }
185
208
 
186
- // Update count
187
- executionCounts.set(hash, Date.now());
209
+ // Record execution timestamp
210
+ lastExecutionTimes.set(hash, Date.now());
188
211
  return true;
189
212
  }
190
213
 
191
214
  /**
192
- * Reset execution counts (for testing or between runs)
215
+ * Reset execution timestamps (for testing or between runs)
193
216
  */
194
217
  export function resetExecutionCounts(): void {
195
- executionCounts.clear();
218
+ lastExecutionTimes.clear();
196
219
  }
197
220
 
198
221
  /**
@@ -260,10 +283,10 @@ export function getSecurityPolicy(configPath?: string): SecurityPolicy {
260
283
  }
261
284
 
262
285
  /**
263
- * Show warning about dangerous command and wait for user to cancel
286
+ * Show warning about dangerous command and wait for user to cancel.
287
+ * Resolves true after the timeout (continue), or false immediately on CTRL+C (cancel).
264
288
  */
265
289
  export async function showDangerousCommandWarning(command: string, timeoutSeconds: number = 5): Promise<boolean> {
266
- const inquirer = (await import('inquirer')).default;
267
290
  const readline = await import('readline');
268
291
 
269
292
  console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
@@ -271,29 +294,38 @@ export async function showDangerousCommandWarning(command: string, timeoutSecond
271
294
  console.log(chalk.red(' This could potentially harm your system.'));
272
295
  console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
273
296
 
274
- // Set up timeout
275
- const timeout = setTimeout(() => {
276
- return true; // Continue after timeout
277
- }, timeoutSeconds * 1000);
278
-
279
- // Set up readline for immediate cancel
280
297
  const rl = readline.createInterface({
281
298
  input: process.stdin,
282
299
  output: process.stdout,
283
300
  });
284
301
 
285
302
  return new Promise((resolve) => {
303
+ const timer = setTimeout(() => {
304
+ rl.close();
305
+ resolve(true);
306
+ }, timeoutSeconds * 1000);
307
+
286
308
  rl.on('SIGINT', () => {
287
- clearTimeout(timeout);
309
+ clearTimeout(timer);
288
310
  rl.close();
289
311
  console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
290
312
  resolve(false);
291
313
  });
292
-
293
- // If timeout expires, resolve without waiting for readline
294
- setTimeout(() => {
295
- rl.close();
296
- resolve(true);
297
- }, timeoutSeconds * 1000);
298
314
  });
299
315
  }
316
+
317
+ // Security response handling for GUI integration
318
+ export async function handleSecurityResponse(response: string): Promise<boolean> {
319
+ // Normalize response
320
+ const normalized = response.trim().toLowerCase();
321
+
322
+ // Accept 'y' or 'yes' as positive response
323
+ if (normalized === 'y' || normalized === 'yes') {
324
+ console.log('Security response: ALLOWED');
325
+ return true;
326
+ }
327
+
328
+ // Reject any other response
329
+ console.log('Security response: DENIED');
330
+ return false;
331
+ }
package/src/substitute.ts CHANGED
@@ -7,14 +7,39 @@ import { sanitizePath } from './config.js';
7
7
 
8
8
  /**
9
9
  * Replaces all {{var}} patterns in the content with values from the variables object.
10
+ * Supports nested variable expansion - if a variable's value contains {{other_var}},
11
+ * it will be expanded iteratively until no more placeholders remain or maxIterations is reached.
10
12
  */
11
13
  export function substituteVariables(
12
14
  content: string,
13
- variables: Record<string, string>
15
+ variables: Record<string, string>,
16
+ maxIterations: number = 10
14
17
  ): string {
15
- return content.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, varName) => {
16
- return variables[varName] ?? `{{${varName}}}`;
17
- });
18
+ let result = content;
19
+ let iteration = 0;
20
+
21
+ // Keep expanding until no more placeholders remain or we hit the limit
22
+ while (/\{\{[^}]+\}\}/.test(result) && iteration < maxIterations) {
23
+ // Use a more complex regex that captures the full placeholder including spaces
24
+ result = result.replace(/(\{\{\s*)(\w+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
25
+ const val = variables[varName];
26
+ // If variable not found, leave placeholder as-is with original spacing
27
+ if (val === undefined) {
28
+ return `${prefix}${varName}${suffix}`;
29
+ }
30
+ // Return the value (which may contain more placeholders to expand)
31
+ return val;
32
+ });
33
+ iteration++;
34
+
35
+ // Prevent infinite loops by checking if we're stuck
36
+ if (iteration > 1 && result === content) {
37
+ console.warn(chalk.yellow(`Warning: Potential infinite loop detected in variable expansion, stopping after ${iteration} iterations`));
38
+ break;
39
+ }
40
+ }
41
+
42
+ return result;
18
43
  }
19
44
 
20
45
  /**
@@ -44,14 +69,26 @@ export async function processCopyFiles(
44
69
  if (dryRun) {
45
70
  console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
46
71
  } else {
47
- copyDirRecursive(srcPath, destPath, variables, copyFile.substitute_variables || false, copyFile.chmod);
72
+ const dirSubstitute = !!(copyFile.substitute_variables === true || (
73
+ copyFile.substitute_variables === undefined &&
74
+ template.variables &&
75
+ template.variables.length > 0 &&
76
+ Object.keys(variables).length > 0
77
+ ));
78
+ copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
48
79
  }
49
80
  console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
50
81
  } else {
51
82
  // Single file copy
52
83
  if (dryRun) {
53
84
  console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
54
- if (copyFile.substitute_variables) {
85
+ const drySubstitute = !!(copyFile.substitute_variables === true || (
86
+ copyFile.substitute_variables === undefined &&
87
+ template.variables &&
88
+ template.variables.length > 0 &&
89
+ Object.keys(variables).length > 0
90
+ ));
91
+ if (drySubstitute) {
55
92
  console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
56
93
  }
57
94
  if (copyFile.chmod) {
@@ -64,7 +101,15 @@ export async function processCopyFiles(
64
101
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
65
102
 
66
103
  let content = fs.readFileSync(srcPath, 'utf-8');
67
- if (copyFile.substitute_variables) {
104
+ // Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
105
+ // If substitute_variables is explicitly false, do not substitute.
106
+ const shouldSubstitute = !!(copyFile.substitute_variables === true || (
107
+ copyFile.substitute_variables === undefined &&
108
+ template.variables &&
109
+ template.variables.length > 0 &&
110
+ Object.keys(variables).length > 0
111
+ ));
112
+ if (shouldSubstitute) {
68
113
  content = substituteVariables(content, variables);
69
114
  }
70
115
 
@@ -0,0 +1,330 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ // Force a temporary home directory for testing before importing anything from the CLI
7
+ const testHome = path.join(process.cwd(), '.test-home-env');
8
+ process.env.HOME = testHome;
9
+
10
+ // Import the init command to test the env scanning functionality
11
+ import { init } from '../src/commands/initCommand.js';
12
+ import { loadConfig, saveConfig, PtConfig, getConfigPath } from '../src/config.js';
13
+
14
+ // Helper to clean up test directories
15
+ function cleanup(...paths: string[]) {
16
+ for (const p of paths) {
17
+ if (fs.existsSync(p)) {
18
+ fs.rmSync(p, { recursive: true, force: true });
19
+ }
20
+ }
21
+ }
22
+
23
+ // Helper to set up a config with a template for testing
24
+ function setupTestConfig(templateName: string, template: any): PtConfig {
25
+ const config: PtConfig = {
26
+ version: '3.0',
27
+ templates: {
28
+ [templateName]: template
29
+ }
30
+ };
31
+ saveConfig(config);
32
+ return config;
33
+ }
34
+
35
+ test('init scans parent directories for .env files and pre-fills variables', async () => {
36
+ const parentEnvDir = path.join(process.cwd(), 'test-parent-env');
37
+ const projectDest = path.join(parentEnvDir, 'test-env-project');
38
+ const templateRoot = path.join(process.cwd(), 'test-env-tpl-root');
39
+ cleanup(projectDest, templateRoot, testHome);
40
+
41
+ // Create template root with a file containing variables
42
+ fs.mkdirSync(templateRoot, { recursive: true });
43
+ fs.writeFileSync(
44
+ path.join(templateRoot, 'README.md'),
45
+ '# {{ project_name }}\n\nBy {{ author }}\n'
46
+ );
47
+
48
+ // Set up config with template that has variables and copy_files
49
+ setupTestConfig('env-tpl', {
50
+ description: 'Template with variables',
51
+ templateRoot: templateRoot,
52
+ folders: [],
53
+ variables: [
54
+ { name: 'project_name', prompt: 'Project name:', required: true },
55
+ { name: 'author', prompt: 'Author:', default: 'Unknown' }
56
+ ],
57
+ copy_files: [
58
+ { src: 'README.md', dest: 'README.md', substitute_variables: true }
59
+ ]
60
+ });
61
+
62
+ // Create parent directory with .env file containing matching variables
63
+ fs.mkdirSync(parentEnvDir, { recursive: true });
64
+ fs.writeFileSync(
65
+ path.join(parentEnvDir, '.env'),
66
+ `project_name=EnvProject\nauthor=EnvAuthor\n`
67
+ );
68
+
69
+ // Run init from within the parent directory
70
+ await init('env-tpl', projectDest, {
71
+ yes: true,
72
+ skipPostConfig: true
73
+ });
74
+
75
+ // Verify .env variables were used
76
+ const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
77
+ assert.ok(readme.includes('EnvProject'), 'README should contain project_name from .env');
78
+ assert.ok(readme.includes('EnvAuthor'), 'README should contain author from .env');
79
+ assert.ok(!readme.includes('{{ project_name }}'), 'README should NOT contain variable placeholder');
80
+
81
+ cleanup(projectDest, templateRoot, parentEnvDir, testHome);
82
+ });
83
+
84
+ test('init --vars overrides .env variables', async () => {
85
+ const parentEnvDir = path.join(process.cwd(), 'test-parent-override');
86
+ const projectDest = path.join(parentEnvDir, 'test-override-project');
87
+ const templateRoot = path.join(process.cwd(), 'test-override-tpl-root');
88
+ cleanup(projectDest, templateRoot, testHome);
89
+
90
+ // Create template root with a file containing variables
91
+ fs.mkdirSync(templateRoot, { recursive: true });
92
+ fs.writeFileSync(
93
+ path.join(templateRoot, 'config.txt'),
94
+ 'name={{ project_name }}\n'
95
+ );
96
+
97
+ // Set up config with template that has variables and copy_files
98
+ setupTestConfig('override-tpl', {
99
+ description: 'Template with variables',
100
+ templateRoot: templateRoot,
101
+ folders: [],
102
+ variables: [
103
+ { name: 'project_name', prompt: 'Project name:', required: true }
104
+ ],
105
+ copy_files: [
106
+ { src: 'config.txt', dest: 'config.txt', substitute_variables: true }
107
+ ]
108
+ });
109
+
110
+ // Create parent directory with .env file
111
+ fs.mkdirSync(parentEnvDir, { recursive: true });
112
+ fs.writeFileSync(
113
+ path.join(parentEnvDir, '.env'),
114
+ `project_name=EnvProject\n`
115
+ );
116
+
117
+ // Run init with --vars to override .env value
118
+ await init('override-tpl', projectDest, {
119
+ yes: true,
120
+ skipPostConfig: true,
121
+ vars: 'project_name=CLIProject'
122
+ });
123
+
124
+ // Verify --vars value was used instead of .env value
125
+ const configContent = fs.readFileSync(path.join(projectDest, 'config.txt'), 'utf-8');
126
+ assert.ok(configContent.includes('CLIProject'), 'config.txt should contain --vars value');
127
+ assert.ok(!configContent.includes('EnvProject'), 'config.txt should NOT contain .env value');
128
+
129
+ cleanup(projectDest, templateRoot, parentEnvDir, testHome);
130
+ });
131
+
132
+ test('init uses .env from immediate parent directory', async () => {
133
+ const parentEnvDir = path.join(process.cwd(), 'test-immediate-parent');
134
+ const projectDest = path.join(parentEnvDir, 'test-immediate-parent-project');
135
+ const templateRoot = path.join(process.cwd(), 'test-immediate-parent-tpl-root');
136
+ cleanup(projectDest, templateRoot, testHome);
137
+
138
+ // Create template root with a file containing variables
139
+ fs.mkdirSync(templateRoot, { recursive: true });
140
+ fs.writeFileSync(
141
+ path.join(templateRoot, 'README.md'),
142
+ '# {{ project_name }}\n'
143
+ );
144
+
145
+ // Set up config with template that has variables and copy_files
146
+ setupTestConfig('immediate-parent-tpl', {
147
+ description: 'Template with variables',
148
+ templateRoot: templateRoot,
149
+ folders: [],
150
+ variables: [
151
+ { name: 'project_name', prompt: 'Project name:', required: true }
152
+ ],
153
+ copy_files: [
154
+ { src: 'README.md', dest: 'README.md', substitute_variables: true }
155
+ ]
156
+ });
157
+
158
+ // Create immediate parent directory with .env file
159
+ fs.mkdirSync(parentEnvDir, { recursive: true });
160
+ fs.writeFileSync(
161
+ path.join(parentEnvDir, '.env'),
162
+ `project_name=ImmediateParent\n`
163
+ );
164
+
165
+ // Run init from within the parent directory
166
+ await init('immediate-parent-tpl', projectDest, {
167
+ yes: true,
168
+ skipPostConfig: true
169
+ });
170
+
171
+ // Verify .env variable was used
172
+ const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
173
+ assert.ok(readme.includes('ImmediateParent'), 'README should contain project_name from immediate parent .env');
174
+
175
+ cleanup(projectDest, templateRoot, parentEnvDir, testHome);
176
+ });
177
+
178
+ test('init scans multiple parent directories for .env files', async () => {
179
+ const topEnvDir = path.join(process.cwd(), 'test-multi-parent');
180
+ const midEnvDir = path.join(topEnvDir, 'test-mid-env');
181
+ const projectDest = path.join(midEnvDir, 'test-multi-parent-project');
182
+ const templateRoot = path.join(process.cwd(), 'test-multi-parent-tpl-root');
183
+ cleanup(projectDest, templateRoot, testHome);
184
+
185
+ // Create template root with a file containing variables
186
+ fs.mkdirSync(templateRoot, { recursive: true });
187
+ fs.writeFileSync(
188
+ path.join(templateRoot, 'README.md'),
189
+ '# {{ project_name }}\n\nBy {{ author }}\n'
190
+ );
191
+
192
+ // Set up config with template that has variables and copy_files
193
+ setupTestConfig('multi-parent-tpl', {
194
+ description: 'Template with variables',
195
+ templateRoot: templateRoot,
196
+ folders: [],
197
+ variables: [
198
+ { name: 'project_name', prompt: 'Project name:', required: true },
199
+ { name: 'author', prompt: 'Author:', default: 'Unknown' }
200
+ ],
201
+ copy_files: [
202
+ { src: 'README.md', dest: 'README.md', substitute_variables: true }
203
+ ]
204
+ });
205
+
206
+ // Create nested directory structure with .env files at different levels
207
+ fs.mkdirSync(topEnvDir, { recursive: true });
208
+ fs.mkdirSync(midEnvDir, { recursive: true });
209
+
210
+ // .env file at the top level with project_name
211
+ fs.writeFileSync(
212
+ path.join(topEnvDir, '.env'),
213
+ `project_name=TopLevel\n`
214
+ );
215
+
216
+ // .env file at the middle level with author
217
+ fs.writeFileSync(
218
+ path.join(midEnvDir, '.env'),
219
+ `author=MidLevelAuthor\n`
220
+ );
221
+
222
+ // Run init from the deepest directory
223
+ await init('multi-parent-tpl', projectDest, {
224
+ yes: true,
225
+ skipPostConfig: true
226
+ });
227
+
228
+ // Verify .env variables from different levels were used
229
+ const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
230
+ assert.ok(readme.includes('TopLevel'), 'README should contain project_name from top level .env');
231
+ assert.ok(readme.includes('MidLevelAuthor'), 'README should contain author from middle level .env');
232
+
233
+ cleanup(projectDest, templateRoot, topEnvDir, midEnvDir, testHome);
234
+ });
235
+
236
+ test('init uses .env values with quoted strings', async () => {
237
+ const parentEnvDir = path.join(process.cwd(), 'test-quoted-parent');
238
+ const projectDest = path.join(parentEnvDir, 'test-quoted-strings-project');
239
+ const templateRoot = path.join(process.cwd(), 'test-quoted-strings-tpl-root');
240
+ cleanup(projectDest, templateRoot, testHome);
241
+
242
+ // Create template root with a file containing variables
243
+ fs.mkdirSync(templateRoot, { recursive: true });
244
+ fs.writeFileSync(
245
+ path.join(templateRoot, 'README.md'),
246
+ '# {{ project_name }}\n\nBy {{ author }}\n'
247
+ );
248
+
249
+ // Set up config with template that has variables and copy_files
250
+ setupTestConfig('quoted-strings-tpl', {
251
+ description: 'Template with variables',
252
+ templateRoot: templateRoot,
253
+ folders: [],
254
+ variables: [
255
+ { name: 'project_name', prompt: 'Project name:', required: true },
256
+ { name: 'author', prompt: 'Author:', default: 'Unknown' }
257
+ ],
258
+ copy_files: [
259
+ { src: 'README.md', dest: 'README.md', substitute_variables: true }
260
+ ]
261
+ });
262
+
263
+ // Create parent directory with .env file containing quoted strings
264
+ fs.mkdirSync(parentEnvDir, { recursive: true });
265
+ fs.writeFileSync(
266
+ path.join(parentEnvDir, '.env'),
267
+ `project_name="My Quoted Project"\nauthor='Quoted Author'\n`
268
+ );
269
+
270
+ // Run init from within the parent directory
271
+ await init('quoted-strings-tpl', projectDest, {
272
+ yes: true,
273
+ skipPostConfig: true
274
+ });
275
+
276
+ // Verify quoted strings were parsed correctly
277
+ const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
278
+ assert.ok(readme.includes('My Quoted Project'), 'README should contain project_name without quotes');
279
+ assert.ok(readme.includes('Quoted Author'), 'README should contain author without quotes');
280
+ assert.ok(!readme.includes('"My Quoted Project"'), 'README should NOT contain quotes around project_name');
281
+ assert.ok(!readme.includes("'Quoted Author'"), 'README should NOT contain quotes around author');
282
+
283
+ cleanup(projectDest, templateRoot, parentEnvDir, testHome);
284
+ });
285
+
286
+ test('init uses .env values with comments and empty lines', async () => {
287
+ const parentEnvDir = path.join(process.cwd(), 'test-comments-parent');
288
+ const projectDest = path.join(parentEnvDir, 'test-comments-project');
289
+ const templateRoot = path.join(process.cwd(), 'test-comments-tpl-root');
290
+ cleanup(projectDest, templateRoot, testHome);
291
+
292
+ // Create template root with a file containing variables
293
+ fs.mkdirSync(templateRoot, { recursive: true });
294
+ fs.writeFileSync(
295
+ path.join(templateRoot, 'README.md'),
296
+ '# {{ project_name }}\n'
297
+ );
298
+
299
+ // Set up config with template that has variables and copy_files
300
+ setupTestConfig('comments-tpl', {
301
+ description: 'Template with variables',
302
+ templateRoot: templateRoot,
303
+ folders: [],
304
+ variables: [
305
+ { name: 'project_name', prompt: 'Project name:', required: true }
306
+ ],
307
+ copy_files: [
308
+ { src: 'README.md', dest: 'README.md', substitute_variables: true }
309
+ ]
310
+ });
311
+
312
+ // Create parent directory with .env file containing comments and empty lines
313
+ fs.mkdirSync(parentEnvDir, { recursive: true });
314
+ fs.writeFileSync(
315
+ path.join(parentEnvDir, '.env'),
316
+ `# This is a comment\n\nproject_name=CommentProject\n\n# Another comment\n`
317
+ );
318
+
319
+ // Run init from within the parent directory
320
+ await init('comments-tpl', projectDest, {
321
+ yes: true,
322
+ skipPostConfig: true
323
+ });
324
+
325
+ // Verify .env variable was used (comments and empty lines should be ignored)
326
+ const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
327
+ assert.ok(readme.includes('CommentProject'), 'README should contain project_name from .env');
328
+
329
+ cleanup(projectDest, templateRoot, parentEnvDir, testHome);
330
+ });