@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/README.md CHANGED
@@ -45,13 +45,15 @@ graph LR
45
45
  - Initialize new projects from learned templates
46
46
  - Define template variables for dynamic file customization
47
47
  - **Automatic Variable Detection:** Scans text files for `{{ var }}` syntax during `learn`/`update`
48
+ - **Nested Variable Expansion (v0.36.0+):** Variables can contain other variable placeholders that are resolved iteratively
49
+ - **Parent Directory `.env` File Scanning:** Automatically scans parent directories for `.env` files and uses their values as defaults
48
50
  - Auto-detect and suggest post-config setup tasks
49
51
  - Configure global post-config tasks in `~/.pt/config.yaml` (apply to all projects)
50
52
  - Baked-in defaults for common project types (javascript, python, godot, etc.)
51
53
  - Share templates or use as an API with JSON export/import
52
54
  - **Direct JSON scaffolding:** Initialize projects from a JSON file without registering in `config.yaml`
53
55
  - **Portable template configs:** `.pt-template.json` files make shared directories fully self-describing
54
- - Fully supports non-interactive mode (`--yes`, `--vars`) for AI agent automation
56
+ - Fully supports non-interactive mode (`--yes`, `--vars`, `--name`, `--desc`) for AI agent automation
55
57
 
56
58
  ## Quick Start
57
59
 
@@ -21,6 +21,7 @@ export function defaultPostConfigCommand(options = {}) {
21
21
  console.error('You must provide --json <data> to set the default post-config array.');
22
22
  }
23
23
  else {
24
- console.log('Current default post-config tasks:', config.default_post_config || []);
24
+ const tasks = config.default_post_config || [];
25
+ console.log(JSON.stringify(tasks, null, 2));
25
26
  }
26
27
  }
@@ -5,6 +5,61 @@ import { loadConfig, sanitizePath } from '../config.js';
5
5
  import chalk from 'chalk';
6
6
  import { processCopyFiles } from '../substitute.js';
7
7
  import { execSync } from 'child_process';
8
+ /**
9
+ * Scan parent directories for .env files and parse their variables.
10
+ * Returns a map of variable names to their values, supporting:
11
+ * - KEY=VALUE format
12
+ * - KEY="VALUE with spaces" format
13
+ * - KEY='VALUE with spaces' format
14
+ * - Comments (lines starting with #)
15
+ * - Empty lines
16
+ */
17
+ function scanEnvForVariables(targetPath) {
18
+ const envVars = {};
19
+ let currentDir = path.resolve(targetPath);
20
+ // Scan up to 5 parent directories for .env files
21
+ const maxDepth = 5;
22
+ for (let depth = 0; depth < maxDepth; depth++) {
23
+ const envPath = path.join(currentDir, '.env');
24
+ if (fs.existsSync(envPath)) {
25
+ try {
26
+ const content = fs.readFileSync(envPath, 'utf-8');
27
+ const lines = content.split('\n');
28
+ for (const line of lines) {
29
+ const trimmed = line.trim();
30
+ // Skip empty lines and comments
31
+ if (!trimmed || trimmed.startsWith('#')) {
32
+ continue;
33
+ }
34
+ // Match KEY=VALUE patterns
35
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
36
+ if (match) {
37
+ const key = match[1];
38
+ let value = match[2];
39
+ // Remove surrounding quotes if present
40
+ if ((value.startsWith('"') && value.endsWith('"')) ||
41
+ (value.startsWith("'") && value.endsWith("'"))) {
42
+ value = value.slice(1, -1);
43
+ }
44
+ envVars[key] = value;
45
+ }
46
+ }
47
+ }
48
+ catch (err) {
49
+ // Silently skip unreadable .env files
50
+ continue;
51
+ }
52
+ }
53
+ // Move to parent directory
54
+ const parentDir = path.dirname(currentDir);
55
+ if (parentDir === currentDir) {
56
+ // Reached filesystem root
57
+ break;
58
+ }
59
+ currentDir = parentDir;
60
+ }
61
+ return envVars;
62
+ }
8
63
  export async function init(targetName, destPath, options = {}) {
9
64
  const config = loadConfig();
10
65
  let typeName = targetName;
@@ -86,6 +141,16 @@ export async function init(targetName, destPath, options = {}) {
86
141
  // Handle Variables
87
142
  let variables = {};
88
143
  if (template.variables && template.variables.length > 0) {
144
+ // Scan parent directories for .env files and pre-fill variables
145
+ const envVars = scanEnvForVariables(resolvedDest);
146
+ // Merge .env variables into variables (with lower priority than --vars)
147
+ if (Object.keys(envVars).length > 0) {
148
+ for (const [key, value] of Object.entries(envVars)) {
149
+ if (!variables[key]) {
150
+ variables[key] = value;
151
+ }
152
+ }
153
+ }
89
154
  if (options.vars) {
90
155
  // Parse --vars "key=val,key2=val2"
91
156
  const pairs = options.vars.split(',').map((p) => p.trim());
@@ -153,25 +218,26 @@ export async function init(targetName, destPath, options = {}) {
153
218
  if (fs.existsSync(srcPath)) {
154
219
  if (options.dryRun) {
155
220
  console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
156
- const ext = path.extname(file.src);
157
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
158
- console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
159
- }
221
+ console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
160
222
  continue;
161
223
  }
162
- const fileContent = fs.readFileSync(srcPath, 'utf-8');
224
+ let fileContent = fs.readFileSync(srcPath, 'utf-8');
225
+ // Substitute variables in post_copy files if template has variables
226
+ if (template.variables && template.variables.length > 0) {
227
+ const { substituteVariables } = await import('../substitute.js');
228
+ fileContent = substituteVariables(fileContent, variables);
229
+ }
163
230
  const destDir = path.dirname(destPath);
164
231
  fs.mkdirSync(destDir, { recursive: true });
165
232
  fs.writeFileSync(destPath, fileContent);
166
- // Auto-chmod for executables
167
- const ext = path.extname(file.src);
168
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
169
- try {
170
- fs.chmodSync(destPath, 0o755);
171
- }
172
- catch (e) {
173
- // chmod not available (Windows)
174
- }
233
+ // post_copy files are executables by definition — always chmod
234
+ try {
235
+ // Check if source had execute permissions, otherwise default to 0o755
236
+ const srcStat = fs.statSync(srcPath);
237
+ fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
238
+ }
239
+ catch (e) {
240
+ // chmod not available (Windows)
175
241
  }
176
242
  console.log(chalk.green(" ✓ " + (file.dest || file.src)));
177
243
  }
@@ -190,7 +256,38 @@ export async function init(targetName, destPath, options = {}) {
190
256
  }
191
257
  // Use template post_config tasks
192
258
  const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName) || [];
193
- if (allTasks.length > 0) {
259
+ if (allTasks.length > 0 && !options.skipPostConfig) {
260
+ // SECURITY CHECK: Validate template safety before running post_config tasks
261
+ const { validateTemplateSecurity } = await import('../safety.js');
262
+ const { valid, errors, warnings } = validateTemplateSecurity(template);
263
+ if (!valid) {
264
+ console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
265
+ for (const err of errors) {
266
+ console.error(chalk.red(` - ${err}`));
267
+ }
268
+ process.exit(1);
269
+ }
270
+ if (warnings.length > 0) {
271
+ console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
272
+ for (const warn of warnings) {
273
+ console.warn(chalk.yellow(` - ${warn}`));
274
+ }
275
+ if (!options.yes) {
276
+ const { proceed } = await inquirer.prompt({
277
+ type: 'confirm',
278
+ name: 'proceed',
279
+ message: chalk.red('Are you sure you want to run these post-config tasks?'),
280
+ default: false
281
+ });
282
+ if (!proceed) {
283
+ console.log(chalk.yellow("Post-config tasks aborted by user."));
284
+ return;
285
+ }
286
+ }
287
+ else {
288
+ console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
289
+ }
290
+ }
194
291
  // Determine which tasks to include
195
292
  let selectedTaskNames = [];
196
293
  if (options.skipPostConfig) {
@@ -9,7 +9,16 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
9
9
  // Phase 1: Remote Check
10
10
  if (sourcePath.startsWith('http')) {
11
11
  console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
12
- resolvedPath = await downloadAndExtract(sourcePath);
12
+ try {
13
+ resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
14
+ }
15
+ catch (err) {
16
+ if (err.message === 'Download cancelled by user due to untrusted source') {
17
+ console.log(chalk.yellow('Download cancelled. Exiting.'));
18
+ process.exit(0);
19
+ }
20
+ throw err;
21
+ }
13
22
  }
14
23
  else {
15
24
  resolvedPath = path.resolve(sourcePath);
@@ -65,6 +74,8 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
65
74
  }
66
75
  }
67
76
  else {
77
+ // Track whether name came from .info.md or JSON (not user-provided)
78
+ const nameFromSource = !options.name && (fileTemplateConfig.name || infoName);
68
79
  if (options.name) {
69
80
  targetName = options.name;
70
81
  }
@@ -92,6 +103,34 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
92
103
  targetName = newName;
93
104
  }
94
105
  }
106
+ // If name came from .info.md or JSON, prompt to confirm/edit
107
+ // This prevents accidental overwrites when creating new templates based on existing ones
108
+ if (nameFromSource && !options.json) {
109
+ if (options.yes) {
110
+ // In --yes mode, keep the auto-detected name but warn
111
+ const existingNames = getTemplateNames(config);
112
+ if (existingNames.includes(targetName)) {
113
+ console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
114
+ }
115
+ }
116
+ else {
117
+ const { confirmName } = await inquirer.prompt({
118
+ type: 'confirm',
119
+ name: 'confirmName',
120
+ message: `Use "${targetName}" as the template name?`,
121
+ default: true
122
+ });
123
+ if (!confirmName) {
124
+ const { newName } = await inquirer.prompt({
125
+ type: 'input',
126
+ name: 'newName',
127
+ message: 'Name this template:',
128
+ default: targetName
129
+ });
130
+ targetName = newName;
131
+ }
132
+ }
133
+ }
95
134
  }
96
135
  let description = '';
97
136
  if (options.desc) {
@@ -124,6 +163,8 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
124
163
  }
125
164
  }
126
165
  else {
166
+ // Track whether description came from .info.md or JSON (not user-provided)
167
+ const descFromSource = !options.desc && (fileTemplateConfig.description || infoDesc);
127
168
  if (fileTemplateConfig.description) {
128
169
  description = fileTemplateConfig.description;
129
170
  if (!options.json)
@@ -146,6 +187,34 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
146
187
  });
147
188
  description = newDesc;
148
189
  }
190
+ // If description came from .info.md or JSON, prompt to confirm/edit
191
+ // This prevents accidental overwrites when creating new templates based on existing ones
192
+ if (descFromSource && !options.json) {
193
+ if (options.yes) {
194
+ // In --yes mode, keep the auto-detected description but warn
195
+ const existingNames = getTemplateNames(config);
196
+ if (existingNames.includes(targetName)) {
197
+ console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
198
+ }
199
+ }
200
+ else {
201
+ const { confirmDesc } = await inquirer.prompt({
202
+ type: 'confirm',
203
+ name: 'confirmDesc',
204
+ message: `Use "${description}" as the template description?`,
205
+ default: true
206
+ });
207
+ if (!confirmDesc) {
208
+ const { newDesc } = await inquirer.prompt({
209
+ type: 'input',
210
+ name: 'newDesc',
211
+ message: 'Purpose/Description of this template:',
212
+ default: description
213
+ });
214
+ description = newDesc;
215
+ }
216
+ }
217
+ }
149
218
  }
150
219
  const cliIgnore = options.ignore ? options.ignore.split(',').map((s) => s.trim()).filter(Boolean) : [];
151
220
  const ignorePatterns = [...(config.ignore || []), ...cliIgnore];
@@ -0,0 +1,12 @@
1
+ // pt-cli/src/commands/securityResponseCommand.ts
2
+ // CLI command to handle security responses from GUI
3
+ import { handleSecurityResponse } from '../safety.js';
4
+ export async function securityResponseCommand(response, options = { response: '' }) {
5
+ const result = await handleSecurityResponse(response);
6
+ if (result) {
7
+ console.log('SECURITY_RESPONSE:ALLOWED');
8
+ }
9
+ else {
10
+ console.log('SECURITY_RESPONSE:DENIED');
11
+ }
12
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import chalk from 'chalk';
3
4
  // Command imports
4
5
  import { learn } from './commands/learnCommand.js';
5
6
  import { init } from './commands/initCommand.js';
@@ -9,6 +10,7 @@ import { variablesCommand } from './commands/variablesCommand.js';
9
10
  import { addCommand } from './commands/addCommand.js';
10
11
  import { removeCommand } from './commands/removeCommand.js';
11
12
  import { defaultPostConfigCommand } from './commands/defaultPostConfigCommand.js';
13
+ import { securityResponseCommand } from './commands/securityResponseCommand.js';
12
14
  import pkg from '../package.json' with { type: 'json' };
13
15
  const program = new Command();
14
16
  program
@@ -23,8 +25,23 @@ program
23
25
  .option('--name <name>', 'Template name (skip prompt)')
24
26
  .option('--desc <description>', 'Template description (skip prompt)')
25
27
  .option('--json', 'Output template structure as JSON for sharing instead of saving')
28
+ .option('--allow-untrusted', 'Bypass the trusted-source check for remote URLs (set by GUI after user confirmation)')
26
29
  .action(async (pathArg, options) => {
27
- await learn(pathArg || '.', null, options);
30
+ try {
31
+ await learn(pathArg || '.', null, options);
32
+ }
33
+ catch (err) {
34
+ if (options.json) {
35
+ console.log(JSON.stringify({
36
+ type: 'error',
37
+ message: err.message || String(err)
38
+ }));
39
+ }
40
+ else {
41
+ console.error(chalk.red(`Error: ${err.message || err}`));
42
+ }
43
+ process.exit(1);
44
+ }
28
45
  });
29
46
  program
30
47
  .command('update <templateName> [sourcePath]')
@@ -33,7 +50,13 @@ program
33
50
  .option('-y, --yes', 'Automatically confirm prompts')
34
51
  .option('--desc <description>', 'Template description (skip prompt)')
35
52
  .action(async (templateName, sourcePath, options) => {
36
- await learn(sourcePath || '.', templateName, options);
53
+ try {
54
+ await learn(sourcePath || '.', templateName, options);
55
+ }
56
+ catch (err) {
57
+ console.error(chalk.red(`Error: ${err.message || err}`));
58
+ process.exit(1);
59
+ }
37
60
  });
38
61
  program
39
62
  .command('init [templateName] [destPath]')
@@ -80,4 +103,10 @@ program
80
103
  .description('Remove a learned template from the config')
81
104
  .option('-y, --yes', 'Automatically confirm removal')
82
105
  .action(removeCommand);
106
+ program
107
+ .command('security-response <response>')
108
+ .description('Handle security response from GUI')
109
+ .action(async (response) => {
110
+ await securityResponseCommand(response);
111
+ });
83
112
  program.parse(process.argv);
package/dist/remote.js CHANGED
@@ -8,26 +8,42 @@ import { extract } from 'tar'; // You'll need: npm install tar
8
8
  import chalk from 'chalk';
9
9
  import { isTrustedSource, logSecurityEvent } from './safety.js';
10
10
  import { loadConfig } from './config.js';
11
- export async function downloadAndExtract(url) {
11
+ export async function downloadAndExtract(url, isJsonMode = false, allowUntrusted = false) {
12
12
  // Load security policy
13
13
  const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
14
14
  const config = loadConfig();
15
15
  const securityPolicy = config.security || {
16
16
  trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
17
17
  };
18
- // SECURITY CHECK: Verify source is trusted
19
- if (!isTrustedSource(url, securityPolicy.trustedSources)) {
20
- console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
21
- console.log(chalk.yellow(' Only use templates from trusted sources'));
22
- const inquirer = (await import('inquirer')).default;
23
- const response = await inquirer.prompt({
24
- type: 'confirm',
25
- name: 'proceed',
26
- message: chalk.red('Continue anyway?'),
27
- default: false
28
- });
29
- if (!response.proceed) {
30
- throw new Error('Download cancelled by user due to untrusted source');
18
+ // SECURITY CHECK: Verify source is trusted (skipped when --allow-untrusted is passed)
19
+ if (!allowUntrusted && !isTrustedSource(url, securityPolicy.trustedSources)) {
20
+ if (isJsonMode) {
21
+ // In JSON mode, output warning as JSON for GUI consumption.
22
+ // The GUI will show a confirmation dialog and, if the user says YES,
23
+ // re-run `pt learn <url> --json --yes --allow-untrusted`.
24
+ console.log(JSON.stringify({
25
+ type: 'security_warning',
26
+ url: url,
27
+ message: `Template from untrusted source: ${url}`,
28
+ warning: 'Only use templates from trusted sources.',
29
+ prompt: 'Continue anyway?',
30
+ default: false
31
+ }));
32
+ process.exit(1);
33
+ }
34
+ else {
35
+ console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
36
+ console.log(chalk.yellow(' Only use templates from trusted sources'));
37
+ const inquirer = (await import('inquirer')).default;
38
+ const response = await inquirer.prompt({
39
+ type: 'confirm',
40
+ name: 'proceed',
41
+ message: chalk.red('Continue anyway?'),
42
+ default: false
43
+ });
44
+ if (!response.proceed) {
45
+ throw new Error('Download cancelled by user due to untrusted source');
46
+ }
31
47
  }
32
48
  }
33
49
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
package/dist/safety.js CHANGED
@@ -12,8 +12,8 @@ const BLOCKED_COMMANDS = [
12
12
  'sudo', 'su', 'su -', 'su root',
13
13
  // Disk operations that could destroy data
14
14
  'dd', 'mkfs', 'fdisk', 'mount', 'umount',
15
- // Shell injection patterns
16
- ';', '|', '&', '&&', '||',
15
+ // Shell injection patterns - removed from blocklist to allow command chaining.
16
+ // These are validated as warnings instead.
17
17
  // Dangerous chmod
18
18
  'chmod 777', 'chmod -R 777', 'chmod 666',
19
19
  // System commands that could kill processes
@@ -26,8 +26,6 @@ const BLOCKED_COMMANDS = [
26
26
  // === DANGEROUS PATTERNS ===
27
27
  // Commands that should trigger a warning but are NOT blocked
28
28
  const DANGEROUS_PATTERNS = [
29
- // Destructive file operations
30
- 'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
31
29
  // Remote downloads + execution
32
30
  'curl', 'wget', 'wget -O', 'curl |', 'wget |',
33
31
  // Script execution
@@ -69,6 +67,31 @@ export function isBlockedCommand(command) {
69
67
  * Check if a command should trigger a warning (but is allowed)
70
68
  */
71
69
  export function isDangerousCommand(command) {
70
+ // Check for destructive file operations targeting absolute paths
71
+ // Regex matches 'rm', 'rmdir', or Windows 'del' followed by optional flags and then an absolute path.
72
+ // Absolute path matches:
73
+ // - Unix: starting with '/' (e.g. /tmp, /usr)
74
+ // - Windows: starting with drive letter (e.g. C:\) or UNC path (\\) or drive-relative '\'
75
+ const parts = command.split(/\s+/);
76
+ const rmIndex = parts.findIndex(p => p === 'rm' || p === 'rmdir' || p === 'del');
77
+ if (rmIndex !== -1) {
78
+ // Check subsequent arguments
79
+ for (let i = rmIndex + 1; i < parts.length; i++) {
80
+ const arg = parts[i];
81
+ if (!arg)
82
+ continue;
83
+ // Skip flags (starting with - or /flag on Windows)
84
+ if (arg.startsWith('-') || (process.platform === 'win32' && arg.startsWith('/'))) {
85
+ continue;
86
+ }
87
+ // Check absolute path patterns
88
+ const isAbsolute = arg.startsWith('/') ||
89
+ (process.platform === 'win32' && (/^[a-zA-Z]:\\/.test(arg) || arg.startsWith('\\\\') || arg.startsWith('\\')));
90
+ if (isAbsolute) {
91
+ return true;
92
+ }
93
+ }
94
+ }
72
95
  for (const pattern of DANGEROUS_PATTERNS) {
73
96
  if (command.includes(pattern)) {
74
97
  return true;
@@ -135,31 +158,31 @@ export function isTrustedSource(url, trustedSources = DEFAULT_SECURITY_POLICY.tr
135
158
  return trustedSources.some(source => url.includes(source));
136
159
  }
137
160
  /**
138
- * Check if a command has been executed too many times (rate limiting)
161
+ * Rate limiting: track the last execution timestamp per command hash
139
162
  */
140
- const executionCounts = new Map();
163
+ const lastExecutionTimes = new Map();
141
164
  const RATE_LIMIT_MS = 1000; // 1 second between identical commands
142
165
  export function canExecute(command, maxCommandsPerRun = 50) {
143
- // Check total count per run
144
- const totalExecuted = Array.from(executionCounts.values()).reduce((sum, count) => sum + count, 0);
166
+ // Check total commands executed this run
167
+ const totalExecuted = lastExecutionTimes.size;
145
168
  if (totalExecuted >= maxCommandsPerRun) {
146
169
  return false;
147
170
  }
148
171
  // Check rate limit for this specific command
149
172
  const hash = crypto.createHash('md5').update(command).digest('hex');
150
- const last = executionCounts.get(hash) || 0;
151
- if (Date.now() - last < RATE_LIMIT_MS) {
173
+ const lastTime = lastExecutionTimes.get(hash) || 0;
174
+ if (Date.now() - lastTime < RATE_LIMIT_MS) {
152
175
  return false;
153
176
  }
154
- // Update count
155
- executionCounts.set(hash, Date.now());
177
+ // Record execution timestamp
178
+ lastExecutionTimes.set(hash, Date.now());
156
179
  return true;
157
180
  }
158
181
  /**
159
- * Reset execution counts (for testing or between runs)
182
+ * Reset execution timestamps (for testing or between runs)
160
183
  */
161
184
  export function resetExecutionCounts() {
162
- executionCounts.clear();
185
+ lastExecutionTimes.clear();
163
186
  }
164
187
  /**
165
188
  * Validate a template's security before execution
@@ -216,35 +239,42 @@ export function getSecurityPolicy(configPath) {
216
239
  return DEFAULT_SECURITY_POLICY;
217
240
  }
218
241
  /**
219
- * Show warning about dangerous command and wait for user to cancel
242
+ * Show warning about dangerous command and wait for user to cancel.
243
+ * Resolves true after the timeout (continue), or false immediately on CTRL+C (cancel).
220
244
  */
221
245
  export async function showDangerousCommandWarning(command, timeoutSeconds = 5) {
222
- const inquirer = (await import('inquirer')).default;
223
246
  const readline = await import('readline');
224
247
  console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
225
248
  console.log(chalk.red(` Command: ${command}`));
226
249
  console.log(chalk.red(' This could potentially harm your system.'));
227
250
  console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
228
- // Set up timeout
229
- const timeout = setTimeout(() => {
230
- return true; // Continue after timeout
231
- }, timeoutSeconds * 1000);
232
- // Set up readline for immediate cancel
233
251
  const rl = readline.createInterface({
234
252
  input: process.stdin,
235
253
  output: process.stdout,
236
254
  });
237
255
  return new Promise((resolve) => {
256
+ const timer = setTimeout(() => {
257
+ rl.close();
258
+ resolve(true);
259
+ }, timeoutSeconds * 1000);
238
260
  rl.on('SIGINT', () => {
239
- clearTimeout(timeout);
261
+ clearTimeout(timer);
240
262
  rl.close();
241
263
  console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
242
264
  resolve(false);
243
265
  });
244
- // If timeout expires, resolve without waiting for readline
245
- setTimeout(() => {
246
- rl.close();
247
- resolve(true);
248
- }, timeoutSeconds * 1000);
249
266
  });
250
267
  }
268
+ // Security response handling for GUI integration
269
+ export async function handleSecurityResponse(response) {
270
+ // Normalize response
271
+ const normalized = response.trim().toLowerCase();
272
+ // Accept 'y' or 'yes' as positive response
273
+ if (normalized === 'y' || normalized === 'yes') {
274
+ console.log('Security response: ALLOWED');
275
+ return true;
276
+ }
277
+ // Reject any other response
278
+ console.log('Security response: DENIED');
279
+ return false;
280
+ }
@@ -4,11 +4,32 @@ import path from 'path';
4
4
  import { sanitizePath } from './config.js';
5
5
  /**
6
6
  * Replaces all {{var}} patterns in the content with values from the variables object.
7
+ * Supports nested variable expansion - if a variable's value contains {{other_var}},
8
+ * it will be expanded iteratively until no more placeholders remain or maxIterations is reached.
7
9
  */
8
- export function substituteVariables(content, variables) {
9
- return content.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, varName) => {
10
- return variables[varName] ?? `{{${varName}}}`;
11
- });
10
+ export function substituteVariables(content, variables, maxIterations = 10) {
11
+ let result = content;
12
+ let iteration = 0;
13
+ // Keep expanding until no more placeholders remain or we hit the limit
14
+ while (/\{\{[^}]+\}\}/.test(result) && iteration < maxIterations) {
15
+ // Use a more complex regex that captures the full placeholder including spaces
16
+ result = result.replace(/(\{\{\s*)(\w+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
17
+ const val = variables[varName];
18
+ // If variable not found, leave placeholder as-is with original spacing
19
+ if (val === undefined) {
20
+ return `${prefix}${varName}${suffix}`;
21
+ }
22
+ // Return the value (which may contain more placeholders to expand)
23
+ return val;
24
+ });
25
+ iteration++;
26
+ // Prevent infinite loops by checking if we're stuck
27
+ if (iteration > 1 && result === content) {
28
+ console.warn(chalk.yellow(`Warning: Potential infinite loop detected in variable expansion, stopping after ${iteration} iterations`));
29
+ break;
30
+ }
31
+ }
32
+ return result;
12
33
  }
13
34
  /**
14
35
  * Processes copy_files tasks from a template.
@@ -30,7 +51,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
30
51
  console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
31
52
  }
32
53
  else {
33
- copyDirRecursive(srcPath, destPath, variables, copyFile.substitute_variables || false, copyFile.chmod);
54
+ const dirSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
55
+ template.variables &&
56
+ template.variables.length > 0 &&
57
+ Object.keys(variables).length > 0));
58
+ copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
34
59
  }
35
60
  console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
36
61
  }
@@ -38,7 +63,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
38
63
  // Single file copy
39
64
  if (dryRun) {
40
65
  console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
41
- if (copyFile.substitute_variables) {
66
+ const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
67
+ template.variables &&
68
+ template.variables.length > 0 &&
69
+ Object.keys(variables).length > 0));
70
+ if (drySubstitute) {
42
71
  console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
43
72
  }
44
73
  if (copyFile.chmod) {
@@ -49,7 +78,13 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
49
78
  // Ensure destination directory exists
50
79
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
51
80
  let content = fs.readFileSync(srcPath, 'utf-8');
52
- if (copyFile.substitute_variables) {
81
+ // Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
82
+ // If substitute_variables is explicitly false, do not substitute.
83
+ const shouldSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
84
+ template.variables &&
85
+ template.variables.length > 0 &&
86
+ Object.keys(variables).length > 0));
87
+ if (shouldSubstitute) {
53
88
  content = substituteVariables(content, variables);
54
89
  }
55
90
  fs.writeFileSync(destPath, content);