@garyr/pt-cli 0.32.1 → 0.36.4

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/index.ts CHANGED
@@ -14,6 +14,7 @@ import { variablesCommand } from './commands/variablesCommand.js';
14
14
  import { addCommand } from './commands/addCommand.js';
15
15
  import { removeCommand } from './commands/removeCommand.js';
16
16
  import { defaultPostConfigCommand } from './commands/defaultPostConfigCommand.js';
17
+ import { securityResponseCommand } from './commands/securityResponseCommand.js';
17
18
 
18
19
  import pkg from '../package.json' with { type: 'json' };
19
20
 
@@ -32,8 +33,21 @@ program
32
33
  .option('--name <name>', 'Template name (skip prompt)')
33
34
  .option('--desc <description>', 'Template description (skip prompt)')
34
35
  .option('--json', 'Output template structure as JSON for sharing instead of saving')
36
+ .option('--allow-untrusted', 'Bypass the trusted-source check for remote URLs (set by GUI after user confirmation)')
35
37
  .action(async (pathArg: string | undefined, options) => {
36
- await learn(pathArg || '.', null, options);
38
+ try {
39
+ await learn(pathArg || '.', null, options);
40
+ } catch (err: any) {
41
+ if (options.json) {
42
+ console.log(JSON.stringify({
43
+ type: 'error',
44
+ message: err.message || String(err)
45
+ }));
46
+ } else {
47
+ console.error(chalk.red(`Error: ${err.message || err}`));
48
+ }
49
+ process.exit(1);
50
+ }
37
51
  });
38
52
 
39
53
  program
@@ -43,7 +57,12 @@ program
43
57
  .option('-y, --yes', 'Automatically confirm prompts')
44
58
  .option('--desc <description>', 'Template description (skip prompt)')
45
59
  .action(async (templateName: string, sourcePath: string | undefined, options) => {
46
- await learn(sourcePath || '.', templateName, options);
60
+ try {
61
+ await learn(sourcePath || '.', templateName, options);
62
+ } catch (err: any) {
63
+ console.error(chalk.red(`Error: ${err.message || err}`));
64
+ process.exit(1);
65
+ }
47
66
  });
48
67
 
49
68
  program
@@ -98,4 +117,11 @@ program
98
117
  .option('-y, --yes', 'Automatically confirm removal')
99
118
  .action(removeCommand);
100
119
 
120
+ program
121
+ .command('security-response <response>')
122
+ .description('Handle security response from GUI')
123
+ .action(async (response: string) => {
124
+ await securityResponseCommand(response);
125
+ });
126
+
101
127
  program.parse(process.argv);
package/src/postconfig.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
  import { execSync } from 'child_process';
4
5
  import chalk from 'chalk';
5
6
  import inquirer from 'inquirer';
6
7
  import { PostConfigTask } from './config.js';
8
+ import {
9
+ isBlockedCommand,
10
+ isDangerousCommand,
11
+ executeWithTimeout,
12
+ logSecurityEvent,
13
+ canExecute,
14
+ showDangerousCommandWarning,
15
+ getSecurityPolicy,
16
+ } from './safety.js';
7
17
 
8
18
  export interface PostConfigOptions {
9
19
  skipPostConfig?: boolean;
@@ -22,6 +32,16 @@ export async function runPostConfig(
22
32
  ): Promise<void> {
23
33
  if (options.skipPostConfig) return;
24
34
 
35
+ // Load security policy
36
+ const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
37
+ const securityPolicy = getSecurityPolicy(configPath);
38
+
39
+ // Check rate limiting
40
+ if (!canExecute('init', securityPolicy.maxCommandsPerRun)) {
41
+ console.log(chalk.yellow('⚠️ Rate limit reached: max commands per run exceeded'));
42
+ return;
43
+ }
44
+
25
45
  // 1. Filter tasks by type
26
46
  const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
27
47
 
@@ -54,18 +74,59 @@ export async function runPostConfig(
54
74
  const progress = `[${i + 1}/${applicableTasks.length}]`;
55
75
 
56
76
  if (task.command) {
77
+ // SECURITY CHECK 1: Blocklist check (NEVER allow these)
78
+ if (isBlockedCommand(task.command)) {
79
+ console.log(chalk.red(`${progress} ⚠️ BLOCKED: ${task.command}`));
80
+ logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
81
+ continue;
82
+ }
83
+
84
+ // SECURITY CHECK 2: Dangerous command warning (but allow execution)
85
+ if (isDangerousCommand(task.command)) {
86
+ console.log(chalk.yellow(`${progress} ⚠️ WARNING: This command may be dangerous: ${task.command}`));
87
+ console.log(chalk.yellow(` Press CTRL+C to cancel, or wait 5s to continue...`));
88
+
89
+ // Wait for user to cancel or timeout
90
+ const allowContinue = await showDangerousCommandWarning(task.command, 5);
91
+ if (!allowContinue) {
92
+ console.log(chalk.yellow(`${progress} ⊘ Command cancelled by user`));
93
+ logSecurityEvent('command_blocked', task.command, projectType, 'blocked');
94
+ continue;
95
+ }
96
+ }
97
+
98
+ // SECURITY CHECK 3: Rate limiting
99
+ if (!canExecute(task.command, securityPolicy.maxCommandsPerRun)) {
100
+ console.log(chalk.red(`${progress} ⚠️ Rate limited: too many commands executed`));
101
+ continue;
102
+ }
103
+
57
104
  if (options.dryRun) {
58
105
  console.log(chalk.gray(` [DRY RUN] Would run: ${task.command}`));
59
106
  } else {
60
107
  try {
61
108
  console.log(chalk.yellow(`\n${progress} Running: ${task.command}`));
62
- execSync(task.command, {
63
- cwd: destPath,
64
- stdio: 'inherit'
65
- });
66
- console.log(chalk.green(' ✓ Command completed successfully'));
109
+
110
+ // SECURITY CHECK 4: Execution timeout
111
+ const result = await executeWithTimeout(
112
+ task.command,
113
+ destPath,
114
+ securityPolicy.maxExecutionTime
115
+ );
116
+
117
+ if (result.timedOut) {
118
+ console.log(chalk.red(` ✗ Command timed out after ${securityPolicy.maxExecutionTime / 1000}s`));
119
+ logSecurityEvent('command_timed_out', task.command, projectType, 'timedout');
120
+ } else if (result.success) {
121
+ console.log(chalk.green(' ✓ Command completed successfully'));
122
+ logSecurityEvent('command_executed', task.command, projectType, 'success');
123
+ } else {
124
+ console.log(chalk.red(` ✗ Command failed: ${result.stderr}`));
125
+ logSecurityEvent('command_executed', task.command, projectType, 'failed');
126
+ }
67
127
  } catch (err) {
68
128
  console.log(chalk.red(' ✗ Command failed'));
129
+ logSecurityEvent('command_executed', task.command, projectType, 'failed');
69
130
  }
70
131
  }
71
132
  }
package/src/remote.ts CHANGED
@@ -5,8 +5,51 @@ import os from 'os';
5
5
  import { Readable } from 'stream';
6
6
  import { finished } from 'stream/promises';
7
7
  import { extract } from 'tar'; // You'll need: npm install tar
8
+ import chalk from 'chalk';
9
+ import { isTrustedSource, logSecurityEvent, getSecurityPolicy } from './safety.js';
10
+ import { loadConfig } from './config.js';
11
+
12
+ export async function downloadAndExtract(url: string, isJsonMode: boolean = false, allowUntrusted: boolean = false): Promise<string> {
13
+ // Load security policy
14
+ const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
15
+ const config = loadConfig();
16
+ const securityPolicy = config.security || {
17
+ trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
18
+ };
19
+
20
+ // SECURITY CHECK: Verify source is trusted (skipped when --allow-untrusted is passed)
21
+ if (!allowUntrusted && !isTrustedSource(url, securityPolicy.trustedSources)) {
22
+ if (isJsonMode) {
23
+ // In JSON mode, output warning as JSON for GUI consumption.
24
+ // The GUI will show a confirmation dialog and, if the user says YES,
25
+ // re-run `pt learn <url> --json --yes --allow-untrusted`.
26
+ console.log(JSON.stringify({
27
+ type: 'security_warning',
28
+ url: url,
29
+ message: `Template from untrusted source: ${url}`,
30
+ warning: 'Only use templates from trusted sources.',
31
+ prompt: 'Continue anyway?',
32
+ default: false
33
+ }));
34
+ process.exit(1);
35
+ } else {
36
+ console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
37
+ console.log(chalk.yellow(' Only use templates from trusted sources'));
38
+
39
+ const inquirer = (await import('inquirer')).default;
40
+ const response = await inquirer.prompt({
41
+ type: 'confirm',
42
+ name: 'proceed',
43
+ message: chalk.red('Continue anyway?'),
44
+ default: false
45
+ });
46
+
47
+ if (!response.proceed) {
48
+ throw new Error('Download cancelled by user due to untrusted source');
49
+ }
50
+ }
51
+ }
8
52
 
9
- export async function downloadAndExtract(url: string): Promise<string> {
10
53
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
11
54
  let downloadUrl = url;
12
55
 
@@ -27,10 +70,21 @@ export async function downloadAndExtract(url: string): Promise<string> {
27
70
  const fileStream = fs.createWriteStream(dest);
28
71
  await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
29
72
 
73
+ // SECURITY: Validate downloaded file before extraction
74
+ const stats = fs.statSync(dest);
75
+ if (stats.size > 50 * 1024 * 1024) { // 50MB limit
76
+ throw new Error('Downloaded template is too large (>50MB)');
77
+ }
78
+
30
79
  // Extract tarball
31
80
  await extract({ file: dest, cwd: tempDir });
32
81
 
33
82
  // Find the actual content folder (archives usually wrap content in a folder)
34
83
  const dirs = fs.readdirSync(tempDir).filter(f => fs.statSync(path.join(tempDir, f)).isDirectory());
35
- return path.join(tempDir, dirs[0]);
84
+ const extractedPath = path.join(tempDir, dirs[0]);
85
+
86
+ // SECURITY: Log successful download
87
+ logSecurityEvent('template_loaded', downloadUrl, 'remote', 'success');
88
+
89
+ return extractedPath;
36
90
  }
package/src/safety.ts ADDED
@@ -0,0 +1,331 @@
1
+ // pt-cli/src/safety.ts
2
+ // Security warnings and safeguards for post_config command execution
3
+ import crypto from 'crypto';
4
+ import os from 'os';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import chalk from 'chalk';
8
+
9
+ // === BLOCKLIST ===
10
+ // Commands that are NEVER allowed (safety-critical operations)
11
+ const BLOCKED_COMMANDS = [
12
+ // Privilege escalation
13
+ 'sudo', 'su', 'su -', 'su root',
14
+ // Disk operations that could destroy data
15
+ 'dd', 'mkfs', 'fdisk', 'mount', 'umount',
16
+ // Shell injection patterns - removed from blocklist to allow command chaining.
17
+ // These are validated as warnings instead.
18
+ // Dangerous chmod
19
+ 'chmod 777', 'chmod -R 777', 'chmod 666',
20
+ // System commands that could kill processes
21
+ 'kill', 'killall', 'pkill', 'fuser',
22
+ // Network operations that could exfiltrate data
23
+ 'nc', 'netcat', 'socat',
24
+ // Package manager with dangerous flags
25
+ 'apt purge', 'apt remove', 'yum remove', 'brew uninstall',
26
+ ];
27
+
28
+ // === DANGEROUS PATTERNS ===
29
+ // Commands that should trigger a warning but are NOT blocked
30
+ const DANGEROUS_PATTERNS = [
31
+ // Remote downloads + execution
32
+ 'curl', 'wget', 'wget -O', 'curl |', 'wget |',
33
+ // Script execution
34
+ 'bash', 'sh', 'python', 'python3', 'node -e', 'node -p',
35
+ // Shell operations
36
+ 'eval', 'exec', 'source',
37
+ // File system manipulation
38
+ 'chmod -R', 'chown -R', 'chgrp -R',
39
+ // PowerShell (Windows)
40
+ 'powershell', 'pwsh', 'Invoke-Expression', 'IEX',
41
+ // macOS-specific
42
+ 'diskutil', 'hdiutil', 'csrutil',
43
+ ];
44
+
45
+ // === DEFAULT CONFIGURATION ===
46
+ export interface SecurityPolicy {
47
+ maxExecutionTime: number; // milliseconds
48
+ enableAuditLogging: boolean;
49
+ trustedSources: string[];
50
+ maxCommandsPerRun: number;
51
+ securityLevel: 'warn' | 'strict';
52
+ }
53
+
54
+ // Default security policy - warning focused
55
+ const DEFAULT_SECURITY_POLICY: SecurityPolicy = {
56
+ maxExecutionTime: 30000, // 30 seconds
57
+ enableAuditLogging: true,
58
+ trustedSources: [
59
+ 'github.com/garyritchie',
60
+ 'git.lyonritchie.com',
61
+ 'github.com/lyonritchie',
62
+ ],
63
+ maxCommandsPerRun: 50,
64
+ securityLevel: 'warn', // Warning-focused mode
65
+ };
66
+
67
+ /**
68
+ * Check if a command is in the blocklist (NEVER allowed)
69
+ * Only truly dangerous operations that could destroy data
70
+ */
71
+ export function isBlockedCommand(command: string): boolean {
72
+ for (const blocked of BLOCKED_COMMANDS) {
73
+ if (command.includes(blocked)) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Check if a command should trigger a warning (but is allowed)
82
+ */
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
+
109
+ for (const pattern of DANGEROUS_PATTERNS) {
110
+ if (command.includes(pattern)) {
111
+ return true;
112
+ }
113
+ }
114
+ return false;
115
+ }
116
+
117
+ /**
118
+ * Execute a command with timeout and error handling
119
+ */
120
+ export async function executeWithTimeout(
121
+ command: string,
122
+ cwd: string,
123
+ timeoutMs: number = 30000
124
+ ): Promise<{ success: boolean; stdout?: string; stderr?: string; timedOut?: boolean }> {
125
+ const { execSync } = await import('child_process');
126
+
127
+ try {
128
+ const output = execSync(command, {
129
+ cwd,
130
+ stdio: 'pipe',
131
+ timeout: timeoutMs,
132
+ encoding: 'utf-8',
133
+ });
134
+ return { success: true, stdout: output };
135
+ } catch (err: any) {
136
+ if (err.code === 'ETIMEDOUT') {
137
+ return { success: false, timedOut: true };
138
+ }
139
+ return {
140
+ success: false,
141
+ stderr: err.stderr || err.message,
142
+ };
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Log a security event to audit log
148
+ */
149
+ export function logSecurityEvent(
150
+ eventType: 'command_executed' | 'command_blocked' | 'command_timed_out' | 'template_loaded',
151
+ command: string,
152
+ templateName: string,
153
+ result: 'success' | 'failed' | 'timedout' | 'blocked'
154
+ ): void {
155
+ const logEntry = {
156
+ timestamp: new Date().toISOString(),
157
+ eventType,
158
+ command,
159
+ template: templateName,
160
+ user: os.userInfo().username,
161
+ result,
162
+ hostname: os.hostname(),
163
+ };
164
+
165
+ const logDir = path.join(os.homedir(), '.pt');
166
+ const logFile = path.join(logDir, 'security-audit.log');
167
+
168
+ try {
169
+ // Ensure log directory exists
170
+ if (!fs.existsSync(logDir)) {
171
+ fs.mkdirSync(logDir, { recursive: true });
172
+ }
173
+
174
+ // Append to audit log
175
+ fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
176
+ } catch (err) {
177
+ // Silently fail if logging fails
178
+ console.warn('Warning: Failed to write security audit log');
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Check if a URL is from a trusted source
184
+ */
185
+ export function isTrustedSource(url: string, trustedSources: string[] = DEFAULT_SECURITY_POLICY.trustedSources): boolean {
186
+ return trustedSources.some(source => url.includes(source));
187
+ }
188
+
189
+ /**
190
+ * Rate limiting: track the last execution timestamp per command hash
191
+ */
192
+ const lastExecutionTimes = new Map<string, number>();
193
+ const RATE_LIMIT_MS = 1000; // 1 second between identical commands
194
+
195
+ export function canExecute(command: string, maxCommandsPerRun: number = 50): boolean {
196
+ // Check total commands executed this run
197
+ const totalExecuted = lastExecutionTimes.size;
198
+ if (totalExecuted >= maxCommandsPerRun) {
199
+ return false;
200
+ }
201
+
202
+ // Check rate limit for this specific command
203
+ const hash = crypto.createHash('md5').update(command).digest('hex');
204
+ const lastTime = lastExecutionTimes.get(hash) || 0;
205
+ if (Date.now() - lastTime < RATE_LIMIT_MS) {
206
+ return false;
207
+ }
208
+
209
+ // Record execution timestamp
210
+ lastExecutionTimes.set(hash, Date.now());
211
+ return true;
212
+ }
213
+
214
+ /**
215
+ * Reset execution timestamps (for testing or between runs)
216
+ */
217
+ export function resetExecutionCounts(): void {
218
+ lastExecutionTimes.clear();
219
+ }
220
+
221
+ /**
222
+ * Validate a template's security before execution
223
+ */
224
+ export function validateTemplateSecurity(
225
+ templateConfig: any,
226
+ securityPolicy: SecurityPolicy = DEFAULT_SECURITY_POLICY
227
+ ): { valid: boolean; errors: string[]; warnings: string[] } {
228
+ const errors: string[] = [];
229
+ const warnings: string[] = [];
230
+
231
+ // Check post_config tasks
232
+ const postConfigTasks = templateConfig.post_config || [];
233
+ for (const task of postConfigTasks) {
234
+ const command = task.command;
235
+ if (!command) continue;
236
+
237
+ // Check for blocked commands
238
+ if (isBlockedCommand(command)) {
239
+ errors.push(`Blocked command in template: ${command}`);
240
+ }
241
+
242
+ // Warn about dangerous commands
243
+ if (isDangerousCommand(command)) {
244
+ warnings.push(`Dangerous command in template: ${command}`);
245
+ }
246
+
247
+ // Check for shell injection patterns
248
+ if (command.includes(';') || command.includes('|') || command.includes('&')) {
249
+ warnings.push(`Shell injection pattern in command: ${command}`);
250
+ }
251
+
252
+ // Check for remote downloads
253
+ if (command.includes('curl') || command.includes('wget')) {
254
+ warnings.push(`Remote download in command: ${command}`);
255
+ }
256
+ }
257
+
258
+ return {
259
+ valid: errors.length === 0,
260
+ errors,
261
+ warnings,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Get security policy from config or use defaults
267
+ */
268
+ export function getSecurityPolicy(configPath?: string): SecurityPolicy {
269
+ // Try to load from config
270
+ if (configPath) {
271
+ try {
272
+ const YAML = require('yaml');
273
+ const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
274
+ if (config.security) {
275
+ return { ...DEFAULT_SECURITY_POLICY, ...config.security };
276
+ }
277
+ } catch (err) {
278
+ // Fall back to defaults
279
+ }
280
+ }
281
+
282
+ return DEFAULT_SECURITY_POLICY;
283
+ }
284
+
285
+ /**
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).
288
+ */
289
+ export async function showDangerousCommandWarning(command: string, timeoutSeconds: number = 5): Promise<boolean> {
290
+ const readline = await import('readline');
291
+
292
+ console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
293
+ console.log(chalk.red(` Command: ${command}`));
294
+ console.log(chalk.red(' This could potentially harm your system.'));
295
+ console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
296
+
297
+ const rl = readline.createInterface({
298
+ input: process.stdin,
299
+ output: process.stdout,
300
+ });
301
+
302
+ return new Promise((resolve) => {
303
+ const timer = setTimeout(() => {
304
+ rl.close();
305
+ resolve(true);
306
+ }, timeoutSeconds * 1000);
307
+
308
+ rl.on('SIGINT', () => {
309
+ clearTimeout(timer);
310
+ rl.close();
311
+ console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
312
+ resolve(false);
313
+ });
314
+ });
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
@@ -44,14 +44,26 @@ export async function processCopyFiles(
44
44
  if (dryRun) {
45
45
  console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
46
46
  } else {
47
- copyDirRecursive(srcPath, destPath, variables, copyFile.substitute_variables || false, copyFile.chmod);
47
+ const dirSubstitute = !!(copyFile.substitute_variables === true || (
48
+ copyFile.substitute_variables === undefined &&
49
+ template.variables &&
50
+ template.variables.length > 0 &&
51
+ Object.keys(variables).length > 0
52
+ ));
53
+ copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
48
54
  }
49
55
  console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
50
56
  } else {
51
57
  // Single file copy
52
58
  if (dryRun) {
53
59
  console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
54
- if (copyFile.substitute_variables) {
60
+ const drySubstitute = !!(copyFile.substitute_variables === true || (
61
+ copyFile.substitute_variables === undefined &&
62
+ template.variables &&
63
+ template.variables.length > 0 &&
64
+ Object.keys(variables).length > 0
65
+ ));
66
+ if (drySubstitute) {
55
67
  console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
56
68
  }
57
69
  if (copyFile.chmod) {
@@ -64,7 +76,15 @@ export async function processCopyFiles(
64
76
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
65
77
 
66
78
  let content = fs.readFileSync(srcPath, 'utf-8');
67
- if (copyFile.substitute_variables) {
79
+ // Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
80
+ // If substitute_variables is explicitly false, do not substitute.
81
+ const shouldSubstitute = !!(copyFile.substitute_variables === true || (
82
+ copyFile.substitute_variables === undefined &&
83
+ template.variables &&
84
+ template.variables.length > 0 &&
85
+ Object.keys(variables).length > 0
86
+ ));
87
+ if (shouldSubstitute) {
68
88
  content = substituteVariables(content, variables);
69
89
  }
70
90
 
@@ -1,24 +0,0 @@
1
- {
2
- "name": "direct-json-test",
3
- "description": "A mock template for direct JSON scaffolding test",
4
- "folders": [
5
- {
6
- "name": "src",
7
- "info": "contains sources",
8
- "children": [
9
- {
10
- "name": "components",
11
- "info": "reusable components"
12
- },
13
- {
14
- "name": "utils",
15
- "info": "utility functions"
16
- }
17
- ]
18
- },
19
- {
20
- "name": "docs",
21
- "info": "documentation folder"
22
- }
23
- ]
24
- }