@garyr/pt-cli 0.36.4 → 0.39.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/doc/usage.md CHANGED
@@ -46,6 +46,77 @@ During `pt learn` or `pt update`, the tool automatically scans text files at the
46
46
  - **Global Suggestions:** Your global variables (defined in `~/.pt/config.yaml`) are automatically injected as additional suggestions during the learn process.
47
47
  - **Updating:** You can add new placeholders to a project folder and run `pt update <template_name>` to automatically register them in your existing template.
48
48
 
49
+ ### Using `.env` Files for Variable Defaults (v0.36.0+)
50
+
51
+ Starting with v0.36.0, `pt` automatically scans parent directories for `.env` files and uses their values as defaults during initialization. This enables powerful configuration patterns:
52
+
53
+ ```bash
54
+ # Create a .env file in your project directory
55
+ echo "prefix='rst_'" > .env
56
+ echo "project='MyProject'" >> .env
57
+
58
+ # Initialize a project - variables are pre-filled from .env
59
+ pt init my-template my-new-project
60
+ ```
61
+
62
+ **Key Features:**
63
+
64
+ - **Automatic Scanning:** Scans up to 3 parent directories for `.env` files
65
+ - **Variable Pre-filling:** Values from `.env` are used as defaults in prompts
66
+ - **Nested Variables:** Supports nested placeholders like `prefix='app_{{ env }}'`
67
+ - **Override Support:** Use `--vars` to override `.env` values: `--vars project=OverriddenProject`
68
+
69
+ **Example with Nested Variables:**
70
+
71
+ ```bash
72
+ # .env file
73
+ prefix='app_{{ env }}'
74
+ env=prod
75
+ project=MyApp
76
+
77
+ # Template README.md.tmpl
78
+ # Content: {{prefix}}_{{project}}
79
+
80
+ # Result: app_prod_MyApp
81
+ ```
82
+
83
+ **Security Note:** `.env` files are not committed to version control. Use `.gitignore` to exclude them:
84
+
85
+ ```bash
86
+ # .gitignore
87
+ .env
88
+ *.env
89
+ ```
90
+
91
+ ### Nested Variable Expansion (v0.36.0+)
92
+
93
+ The `pt` CLI now supports **nested variable expansion** — variables can contain other variable placeholders that are resolved iteratively. This enables powerful configuration patterns:
94
+
95
+ ```bash
96
+ # In .env file:
97
+ prefix='rst_{{ project }}'
98
+ project=MyProject
99
+
100
+ # During initialization, the system will:
101
+ # 1. Load prefix='rst_{{ project }}' from .env
102
+ # 2. Detect that prefix contains a {{ project }} placeholder
103
+ # 3. Resolve {{ project }} to MyProject
104
+ # 4. Set prefix to rst_MyProject
105
+ ```
106
+
107
+ **Use Cases:**
108
+
109
+ - **Project naming conventions:** `prefix='app_{{ env }}'` + `env=prod` → `app_prod`
110
+ - **Path templates:** `template_path='docs/{{ project }}'` + `project=wiki` → `docs/wiki`
111
+ - **Multi-level configurations:** Combine multiple `.env` files with nested references
112
+
113
+ **How it works:**
114
+
115
+ - Variables are expanded iteratively (up to 10 passes) to prevent infinite loops
116
+ - Circular references are detected and stopped gracefully
117
+ - Missing nested variables remain as `{{ variable }}` placeholders
118
+ - Whitespace is preserved: `{{ unknown }}` stays as `{{ unknown }}` (not `{{unknown}}`)
119
+
49
120
  ## Initialize a project
50
121
 
51
122
  ```bash
@@ -76,3 +76,67 @@ The resulting `package.json` in `my-new-project/` will be:
76
76
  "license": "MIT"
77
77
  }
78
78
  ```
79
+
80
+ ## 4. Nested Variable Expansion (v0.36.0+)
81
+
82
+ Starting with v0.36.0, you can use **nested variables** for more complex configurations. Create a `.env` file in your project directory or parent directory:
83
+
84
+ ```bash
85
+ # .env file in parent directory
86
+ prefix='rst_'
87
+ project=MyProject
88
+ ```
89
+
90
+ Then use these variables in your template files:
91
+
92
+ **`templates/README.md.tmpl`**:
93
+ ```markdown
94
+ # {{prefix}}{{project}}
95
+
96
+ This is a nested variable example where:
97
+ - prefix = 'rst_'
98
+ - project = 'MyProject'
99
+ - Result: 'rst_MyProject'
100
+ ```
101
+
102
+ **Even more complex nesting:**
103
+ ```bash
104
+ # .env file
105
+ prefix='app_{{ env }}'
106
+ env=prod
107
+ project=MyApp
108
+ version=2.0
109
+ ```
110
+
111
+ Then in your template:
112
+ ```json
113
+ {
114
+ "name": "{{prefix}}_{{project}}",
115
+ "version": "{{version}}"
116
+ }
117
+ ```
118
+
119
+ This will resolve to:
120
+ ```json
121
+ {
122
+ "name": "app_prod_MyApp",
123
+ "version": "2.0"
124
+ }
125
+ ```
126
+
127
+ **How it works:**
128
+ 1. `pt` scans parent directories for `.env` files
129
+ 2. Loads variables from `.env` as defaults
130
+ 3. Expands nested placeholders iteratively (up to 10 passes)
131
+ 4. Resolves circular references gracefully
132
+
133
+ **Example with nested placeholders:**
134
+ ```bash
135
+ # .env file
136
+ template_path='docs/{{ project }}'
137
+ project=wiki
138
+ ```
139
+
140
+ Result: `template_path` becomes `docs/wiki`
141
+
142
+ **Important:** Missing nested variables remain as `{{ variable }}` placeholders (with preserved whitespace) to help identify configuration issues.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "0.36.4",
3
+ "version": "0.39.1",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -7,23 +7,124 @@ export interface AddOptions {
7
7
  file?: string;
8
8
  }
9
9
 
10
+ /**
11
+ * Validate JSON file exists and contains valid JSON
12
+ */
13
+ function validateJsonFile(filePath: string): { valid: boolean; data?: any; error?: string } {
14
+ try {
15
+ // Check if file exists
16
+ if (!fs.existsSync(filePath)) {
17
+ return {
18
+ valid: false,
19
+ error: `File not found: ${filePath}`
20
+ };
21
+ }
22
+
23
+ // Check file size (reasonable limit to prevent reading huge files)
24
+ const stats = fs.statSync(filePath);
25
+ if (stats.size > 10 * 1024 * 1024) { // 10MB limit
26
+ return {
27
+ valid: false,
28
+ error: `File too large (${stats.size} bytes). Maximum size is 10MB.`
29
+ };
30
+ }
31
+
32
+ // Read and parse JSON
33
+ const content = fs.readFileSync(filePath, 'utf8');
34
+ if (!content.trim()) {
35
+ return {
36
+ valid: false,
37
+ error: 'File is empty'
38
+ };
39
+ }
40
+
41
+ const data = JSON.parse(content);
42
+ return { valid: true, data };
43
+ } catch (e) {
44
+ const error = e as Error;
45
+ return {
46
+ valid: false,
47
+ error: `JSON parse error: ${error.message}`
48
+ };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Validate template structure
54
+ */
55
+ function validateTemplateStructure(data: any): { valid: boolean; error?: string } {
56
+ if (!data || typeof data !== 'object') {
57
+ return {
58
+ valid: false,
59
+ error: 'Template data must be a JSON object'
60
+ };
61
+ }
62
+
63
+ // Basic structure validation
64
+ if (data.description && typeof data.description !== 'string') {
65
+ return {
66
+ valid: false,
67
+ error: 'Template description must be a string'
68
+ };
69
+ }
70
+
71
+ if (data.variables && Array.isArray(data.variables)) {
72
+ for (let i = 0; i < data.variables.length; i++) {
73
+ const v = data.variables[i];
74
+ if (!v.name || typeof v.name !== 'string') {
75
+ return {
76
+ valid: false,
77
+ error: `Variable at index ${i} must have a string 'name' field`
78
+ };
79
+ }
80
+ }
81
+ }
82
+
83
+ return { valid: true };
84
+ }
85
+
10
86
  export function addCommand(name: string, jsonStr: string | undefined, options: AddOptions = {}) {
11
87
  const config = loadConfig();
88
+
89
+ // Determine if we're reading from file or string
90
+ const isFile = !!options.file;
91
+ let data: any;
92
+
12
93
  try {
13
- let data;
14
- if (options.file) {
15
- const filePath = path.resolve(options.file);
16
- data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
94
+ if (isFile) {
95
+ // Validate file first
96
+ const filePath = path.resolve(options.file!);
97
+ const validation = validateJsonFile(filePath);
98
+
99
+ if (!validation.valid) {
100
+ console.error(chalk.red(`Error: ${validation.error}`));
101
+ console.error(chalk.gray(`File: ${filePath}`));
102
+ process.exit(1);
103
+ }
104
+
105
+ data = validation.data;
17
106
  } else if (jsonStr) {
18
- data = JSON.parse(jsonStr);
107
+ // Parse JSON string directly
108
+ try {
109
+ data = JSON.parse(jsonStr);
110
+ } catch (e) {
111
+ const error = e as Error;
112
+ console.error(chalk.red(`Error: Invalid JSON string - ${error.message}`));
113
+ process.exit(1);
114
+ }
19
115
  } else {
20
116
  console.error('Error: Either a JSON string or --file <path> must be provided.');
21
117
  process.exit(1);
22
118
  }
23
119
 
24
- if (!config.templates) config.templates = {};
120
+ // Validate template structure
121
+ const structureValidation = validateTemplateStructure(data);
122
+ if (!structureValidation.valid) {
123
+ console.error(chalk.red(`Error: Invalid template structure - ${structureValidation.error}`));
124
+ process.exit(1);
125
+ }
25
126
 
26
- // Basic validation: ensure we aren't accidentally adding a full config object
127
+ // Check for full config object
27
128
  if (data && data.templates && typeof data.templates === 'object') {
28
129
  console.error(chalk.red('Error: The provided JSON appears to be a full configuration file, not a single template.'));
29
130
  console.error(chalk.gray('If you want to import a specific template from it, extract that template object first.'));
@@ -35,7 +136,7 @@ export function addCommand(name: string, jsonStr: string | undefined, options: A
35
136
  console.log(chalk.green(`✓ Template "${name}" saved successfully.`));
36
137
  } catch (e) {
37
138
  const error = e as Error;
38
- console.error(chalk.red(`Failed to parse template JSON: ${error.message}`));
139
+ console.error(chalk.red(`Failed to process template: ${error.message}`));
39
140
  process.exit(1);
40
141
  }
41
142
  }
@@ -15,13 +15,20 @@ export function configCommand(templateName: string | undefined, options: ConfigO
15
15
  name: templateName,
16
16
  ...config.templates[templateName]
17
17
  };
18
- console.log(JSON.stringify(output, null, 2));
18
+ // Safely drain stdout before allowing the process to close
19
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
20
+ process.exit(0);
21
+ });
19
22
  } else {
20
- console.error(chalk.red(`Error: Template "${templateName}" not found.`));
21
- process.exit(1);
23
+ process.stderr.write(chalk.red(`Error: Template "${templateName}" not found.\n`), () => {
24
+ process.exit(1);
25
+ });
22
26
  }
23
27
  } else {
24
- console.log(JSON.stringify(config, null, 2));
28
+ // Safely drain the entire global config payload
29
+ process.stdout.write(JSON.stringify(config, null, 2) + '\n', () => {
30
+ process.exit(0);
31
+ });
25
32
  }
26
33
  return;
27
34
  }
@@ -14,6 +14,71 @@ export interface InitOptions {
14
14
  file?: string;
15
15
  }
16
16
 
17
+ /**
18
+ * Scan parent directories for .env files and parse their variables.
19
+ * Returns a map of variable names to their values, supporting:
20
+ * - KEY=VALUE format
21
+ * - KEY="VALUE with spaces" format
22
+ * - KEY='VALUE with spaces' format
23
+ * - Comments (lines starting with #)
24
+ * - Empty lines
25
+ */
26
+ function scanEnvForVariables(targetPath: string): Record<string, string> {
27
+ const envVars: Record<string, string> = {};
28
+ let currentDir = path.resolve(targetPath);
29
+
30
+ // Scan up to 5 parent directories for .env files
31
+ const maxDepth = 5;
32
+
33
+ for (let depth = 0; depth < maxDepth; depth++) {
34
+ const envPath = path.join(currentDir, '.env');
35
+
36
+ if (fs.existsSync(envPath)) {
37
+ try {
38
+ const content = fs.readFileSync(envPath, 'utf-8');
39
+ const lines = content.split('\n');
40
+
41
+ for (const line of lines) {
42
+ const trimmed = line.trim();
43
+
44
+ // Skip empty lines and comments
45
+ if (!trimmed || trimmed.startsWith('#')) {
46
+ continue;
47
+ }
48
+
49
+ // Match KEY=VALUE patterns
50
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
51
+ if (match) {
52
+ const key = match[1];
53
+ let value = match[2];
54
+
55
+ // Remove surrounding quotes if present
56
+ if ((value.startsWith('"') && value.endsWith('"')) ||
57
+ (value.startsWith("'") && value.endsWith("'"))) {
58
+ value = value.slice(1, -1);
59
+ }
60
+
61
+ envVars[key] = value;
62
+ }
63
+ }
64
+ } catch (err) {
65
+ // Silently skip unreadable .env files
66
+ continue;
67
+ }
68
+ }
69
+
70
+ // Move to parent directory
71
+ const parentDir = path.dirname(currentDir);
72
+ if (parentDir === currentDir) {
73
+ // Reached filesystem root
74
+ break;
75
+ }
76
+ currentDir = parentDir;
77
+ }
78
+
79
+ return envVars;
80
+ }
81
+
17
82
  export async function init(targetName: string | undefined, destPath: string | undefined, options: InitOptions = {}) {
18
83
  const config = loadConfig();
19
84
 
@@ -103,6 +168,18 @@ export async function init(targetName: string | undefined, destPath: string | un
103
168
  // Handle Variables
104
169
  let variables: Record<string, string> = {};
105
170
  if (template.variables && template.variables.length > 0) {
171
+ // Scan parent directories for .env files and pre-fill variables
172
+ const envVars = scanEnvForVariables(resolvedDest);
173
+
174
+ // Merge .env variables into variables (with lower priority than --vars)
175
+ if (Object.keys(envVars).length > 0) {
176
+ for (const [key, value] of Object.entries(envVars)) {
177
+ if (!variables[key]) {
178
+ variables[key] = value;
179
+ }
180
+ }
181
+ }
182
+
106
183
  if (options.vars) {
107
184
  // Parse --vars "key=val,key2=val2"
108
185
  const pairs = options.vars.split(',').map((p: string) => p.trim());
@@ -21,7 +21,15 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
21
21
  // Phase 1: Remote Check
22
22
  if (sourcePath.startsWith('http')) {
23
23
  console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
24
- resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
24
+ try {
25
+ resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
26
+ } catch (err) {
27
+ if ((err as Error).message === 'Download cancelled by user due to untrusted source') {
28
+ console.log(chalk.yellow('Download cancelled. Exiting.'));
29
+ process.exit(0);
30
+ }
31
+ throw err;
32
+ }
25
33
  } else {
26
34
  resolvedPath = path.resolve(sourcePath);
27
35
  }
@@ -78,6 +86,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
78
86
  process.exit(1);
79
87
  }
80
88
  } else {
89
+ // Track whether name came from .info.md or JSON (not user-provided)
90
+ const nameFromSource = !options.name && (fileTemplateConfig.name || infoName);
91
+
81
92
  if (options.name) {
82
93
  targetName = options.name;
83
94
  } else if (fileTemplateConfig.name) {
@@ -99,6 +110,34 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
99
110
  targetName = newName;
100
111
  }
101
112
  }
113
+
114
+ // If name came from .info.md or JSON, prompt to confirm/edit
115
+ // This prevents accidental overwrites when creating new templates based on existing ones
116
+ if (nameFromSource && !options.json) {
117
+ if (options.yes) {
118
+ // In --yes mode, keep the auto-detected name but warn
119
+ const existingNames = getTemplateNames(config);
120
+ if (existingNames.includes(targetName)) {
121
+ console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
122
+ }
123
+ } else {
124
+ const { confirmName } = await inquirer.prompt({
125
+ type: 'confirm',
126
+ name: 'confirmName',
127
+ message: `Use "${targetName}" as the template name?`,
128
+ default: true
129
+ });
130
+ if (!confirmName) {
131
+ const { newName } = await inquirer.prompt({
132
+ type: 'input',
133
+ name: 'newName',
134
+ message: 'Name this template:',
135
+ default: targetName
136
+ });
137
+ targetName = newName;
138
+ }
139
+ }
140
+ }
102
141
  }
103
142
 
104
143
  let description = '';
@@ -129,6 +168,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
129
168
  }
130
169
  }
131
170
  } else {
171
+ // Track whether description came from .info.md or JSON (not user-provided)
172
+ const descFromSource = !options.desc && (fileTemplateConfig.description || infoDesc);
173
+
132
174
  if (fileTemplateConfig.description) {
133
175
  description = fileTemplateConfig.description;
134
176
  if (!options.json) console.log(chalk.cyan(`Auto-detected template description from JSON: ${description}`));
@@ -146,6 +188,34 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
146
188
  });
147
189
  description = newDesc;
148
190
  }
191
+
192
+ // If description came from .info.md or JSON, prompt to confirm/edit
193
+ // This prevents accidental overwrites when creating new templates based on existing ones
194
+ if (descFromSource && !options.json) {
195
+ if (options.yes) {
196
+ // In --yes mode, keep the auto-detected description but warn
197
+ const existingNames = getTemplateNames(config);
198
+ if (existingNames.includes(targetName)) {
199
+ console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
200
+ }
201
+ } else {
202
+ const { confirmDesc } = await inquirer.prompt({
203
+ type: 'confirm',
204
+ name: 'confirmDesc',
205
+ message: `Use "${description}" as the template description?`,
206
+ default: true
207
+ });
208
+ if (!confirmDesc) {
209
+ const { newDesc } = await inquirer.prompt({
210
+ type: 'input',
211
+ name: 'newDesc',
212
+ message: 'Purpose/Description of this template:',
213
+ default: description
214
+ });
215
+ description = newDesc;
216
+ }
217
+ }
218
+ }
149
219
  }
150
220
 
151
221
  const cliIgnore = options.ignore ? options.ignore.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
@@ -472,11 +542,16 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
472
542
  }
473
543
 
474
544
  if (options.json) {
475
- const output = {
476
- name: targetName,
477
- ...templateConfig
478
- };
479
- console.log(JSON.stringify(output, null, 2));
545
+ const output = {
546
+ name: targetName,
547
+ ...templateConfig
548
+ };
549
+
550
+ // Force the application to wait until every single byte of this JSON string
551
+ // safely clears the operating system's pipe buffer before letting the process die.
552
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
553
+ process.exit(0);
554
+ });
480
555
  return;
481
556
  }
482
557
 
@@ -518,8 +593,8 @@ function extractStructure(dirPath: string, rootPath: string, ignorePatterns?: st
518
593
  let info = "";
519
594
  const gitkeepPath = path.join(fullPath, '.gitkeep.md');
520
595
  const infoPath = path.join(fullPath, '.info.md');
521
- if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
522
- else if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
596
+ if (fs.existsSync(infoPath)) info = fs.readFileSync(infoPath, 'utf-8').trim();
597
+ else if (fs.existsSync(gitkeepPath)) info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
523
598
  nodes.push({ name: entry.name, info: info, children: children });
524
599
  }
525
600
  } catch (e) {}
package/src/config.ts CHANGED
@@ -148,12 +148,24 @@ export function loadConfig(): PtConfig {
148
148
  } catch (err) {
149
149
  const error = err as Error;
150
150
  console.error(chalk.red(`\nError loading config: ${error.message}`));
151
- // If we have a backup, maybe suggest using it
152
151
  const backupPath = getConfigPath() + '.bak';
153
152
  if (fs.existsSync(backupPath)) {
154
153
  console.error(chalk.yellow(`A backup exists at ${backupPath}. You may want to restore it.`));
155
154
  }
156
- process.exit(1);
155
+
156
+ // Allow the event loop to flush console streams out to Godot before dying
157
+ setTimeout(() => {
158
+ process.exit(1);
159
+ }, 5);
160
+
161
+ // 👇 Add this return statement to satisfy the TypeScript compiler
162
+ // The application will terminate before this empty config can be used.
163
+ return {
164
+ version: '3.0',
165
+ templates: {},
166
+ default_post_config: [],
167
+ variables: []
168
+ };
157
169
  }
158
170
  }
159
171
 
package/src/index.ts CHANGED
@@ -39,14 +39,17 @@ program
39
39
  await learn(pathArg || '.', null, options);
40
40
  } catch (err: any) {
41
41
  if (options.json) {
42
- console.log(JSON.stringify({
42
+ // Fix: Ensure the error JSON payload isn't truncated before exiting
43
+ process.stdout.write(JSON.stringify({
43
44
  type: 'error',
44
45
  message: err.message || String(err)
45
- }));
46
+ }) + '\n', () => {
47
+ process.exit(1);
48
+ });
46
49
  } else {
47
50
  console.error(chalk.red(`Error: ${err.message || err}`));
51
+ process.exit(1);
48
52
  }
49
- process.exit(1);
50
53
  }
51
54
  });
52
55
 
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
  /**