@garyr/pt-cli 1.3.1 → 1.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.4.0] - 2026-09-11
11
+
12
+ ### Added
13
+
14
+ - **Post-config variable substitution**: `pt init` now substitutes template variables into `post_config` commands, scripts, and descriptions.
15
+ - **Hyphenated variable name support**: `substituteVariables` now supports variable names containing hyphens (`[a-zA-Z0-9_-]+`).
16
+
17
+ ### Changed
18
+
19
+ - Variable pre-filling from parent `.env` files and `--vars` CLI flags is now performed even when templates do not explicitly define a `variables` block.
20
+ - Post-config security validation checks substituted commands, properly catching dangerous or blocked commands resolved from variables.
21
+ - Project destination directory is guaranteed to be created even when templates contain no folder nodes.
22
+
23
+ ---
24
+
10
25
  ## [1.3.0] - 2026-09-08
11
26
 
12
27
  ### Added
@@ -322,25 +322,25 @@ export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined)
322
322
  // Merge Variables
323
323
  const mergedVarsDef = mergeVariables(loadedTemplates);
324
324
  let variables = {};
325
- if (mergedVarsDef.length > 0) {
326
- // Scan parent directories for .env files and pre-fill variables
327
- const envVars = scanEnvForVariables(resolvedDest);
328
- if (Object.keys(envVars).length > 0) {
329
- for (const [key, value] of Object.entries(envVars)) {
330
- if (!variables[key]) {
331
- variables[key] = value;
332
- }
325
+ // Scan parent directories for .env files and pre-fill variables
326
+ const envVars = scanEnvForVariables(resolvedDest);
327
+ if (Object.keys(envVars).length > 0) {
328
+ for (const [key, value] of Object.entries(envVars)) {
329
+ if (!variables[key]) {
330
+ variables[key] = value;
333
331
  }
334
332
  }
335
- if (options.vars) {
336
- const pairs = options.vars.split(',').map((p) => p.trim());
337
- for (const pair of pairs) {
338
- const [k, ...v] = pair.split('=');
339
- if (k && v.length > 0) {
340
- variables[k.trim()] = v.join('=').trim();
341
- }
333
+ }
334
+ if (options.vars) {
335
+ const pairs = options.vars.split(',').map((p) => p.trim());
336
+ for (const pair of pairs) {
337
+ const [k, ...v] = pair.split('=');
338
+ if (k && v.length > 0) {
339
+ variables[k.trim()] = v.join('=').trim();
342
340
  }
343
341
  }
342
+ }
343
+ if (mergedVarsDef.length > 0) {
344
344
  if (!options.yes) {
345
345
  for (const v of mergedVarsDef) {
346
346
  if (!variables[v.name]) {
@@ -374,6 +374,11 @@ export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined)
374
374
  }
375
375
  }
376
376
  }
377
+ if (Object.keys(variables).length > 0) {
378
+ for (const [key, val] of Object.entries(variables)) {
379
+ variables[key] = substituteVariables(val, variables);
380
+ }
381
+ }
377
382
  // 1. Create structure (deep merge folders across all templates)
378
383
  let mergedFolders = [];
379
384
  for (const { template } of loadedTemplates) {
@@ -471,7 +476,7 @@ export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined)
471
476
  }
472
477
  }
473
478
  let fileContent = fs.readFileSync(srcPath, 'utf-8');
474
- if (mergedVarsDef.length > 0) {
479
+ if (Object.keys(variables).length > 0) {
475
480
  fileContent = substituteVariables(fileContent, variables);
476
481
  }
477
482
  const destDir = path.dirname(destPath);
@@ -515,6 +520,17 @@ export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined)
515
520
  for (const lt of loadedTemplates) {
516
521
  if (lt.template.post_config) {
517
522
  for (const t of lt.template.post_config) {
523
+ if (Object.keys(variables).length > 0) {
524
+ if (t.command) {
525
+ t.command = substituteVariables(t.command, variables);
526
+ }
527
+ if (t.description) {
528
+ t.description = substituteVariables(t.description, variables);
529
+ }
530
+ if (t.script) {
531
+ t.script = substituteVariables(t.script, variables);
532
+ }
533
+ }
518
534
  if (!t.type || t.type === lt.name) {
519
535
  const key = `${t.command || t.script || ''}|${t.description || ''}`;
520
536
  if (taskMap.has(key)) {
@@ -682,6 +698,9 @@ export async function init(targetOrArgs, destPathOrOptions, optionsOrUndefined)
682
698
  }
683
699
  }
684
700
  function createStructure(dirPath, folders, dryRun = false, silent = false) {
701
+ if (!dryRun) {
702
+ fs.mkdirSync(dirPath, { recursive: true });
703
+ }
685
704
  for (const folder of folders) {
686
705
  const fullDirPath = path.join(dirPath, sanitizePath(folder.name));
687
706
  if (dryRun) {
@@ -2,11 +2,12 @@ import path from 'path';
2
2
  import os from 'os';
3
3
  import chalk from 'chalk';
4
4
  import inquirer from 'inquirer';
5
+ import { substituteVariables } from './substitute.js';
5
6
  import { isBlockedCommand, isDangerousCommand, executeWithTimeout, logSecurityEvent, canExecute, showDangerousCommandWarning, getSecurityPolicy, } from './safety.js';
6
7
  /**
7
8
  * Runs post-configuration tasks for a project.
8
9
  */
9
- export async function runPostConfig(destPath, tasks, projectType, options = {}) {
10
+ export async function runPostConfig(destPath, tasks, projectType, options = {}, variables = {}) {
10
11
  if (options.skipPostConfig)
11
12
  return;
12
13
  // Load security policy
@@ -17,8 +18,15 @@ export async function runPostConfig(destPath, tasks, projectType, options = {})
17
18
  console.log(chalk.yellow('⚠️ Rate limit reached: max commands per run exceeded'));
18
19
  return;
19
20
  }
20
- // 1. Filter tasks by type
21
- const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
21
+ // 1. Filter tasks by type and substitute variables
22
+ const applicableTasks = tasks
23
+ .filter(t => !t.type || t.type === projectType)
24
+ .map(t => ({
25
+ ...t,
26
+ command: t.command && Object.keys(variables).length > 0 ? substituteVariables(t.command, variables) : t.command,
27
+ description: t.description && Object.keys(variables).length > 0 ? substituteVariables(t.description, variables) : t.description,
28
+ script: t.script && Object.keys(variables).length > 0 ? substituteVariables(t.script, variables) : t.script,
29
+ }));
22
30
  if (applicableTasks.length === 0) {
23
31
  return;
24
32
  }
@@ -13,7 +13,7 @@ export function substituteVariables(content, variables, maxIterations = 10) {
13
13
  // Keep expanding until no more placeholders remain or we hit the limit
14
14
  while (/\{\{[^}]+\}\}/.test(result) && iteration < maxIterations) {
15
15
  // Use a more complex regex that captures the full placeholder including spaces
16
- result = result.replace(/(\{\{\s*)(\w+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
16
+ result = result.replace(/(\{\{\s*)([a-zA-Z0-9_-]+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
17
17
  const val = variables[varName];
18
18
  // If variable not found, leave placeholder as-is with original spacing
19
19
  if (val === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@garyr/pt-cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Project Template CLI - Learn structures and initialize projects",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -349,27 +349,27 @@ export async function init(
349
349
  const mergedVarsDef = mergeVariables(loadedTemplates);
350
350
  let variables: Record<string, string> = {};
351
351
 
352
- if (mergedVarsDef.length > 0) {
353
- // Scan parent directories for .env files and pre-fill variables
354
- const envVars = scanEnvForVariables(resolvedDest);
355
- if (Object.keys(envVars).length > 0) {
356
- for (const [key, value] of Object.entries(envVars)) {
357
- if (!variables[key]) {
358
- variables[key] = value;
359
- }
352
+ // Scan parent directories for .env files and pre-fill variables
353
+ const envVars = scanEnvForVariables(resolvedDest);
354
+ if (Object.keys(envVars).length > 0) {
355
+ for (const [key, value] of Object.entries(envVars)) {
356
+ if (!variables[key]) {
357
+ variables[key] = value;
360
358
  }
361
359
  }
360
+ }
362
361
 
363
- if (options.vars) {
364
- const pairs = options.vars.split(',').map((p: string) => p.trim());
365
- for (const pair of pairs) {
366
- const [k, ...v] = pair.split('=');
367
- if (k && v.length > 0) {
368
- variables[k.trim()] = v.join('=').trim();
369
- }
362
+ if (options.vars) {
363
+ const pairs = options.vars.split(',').map((p: string) => p.trim());
364
+ for (const pair of pairs) {
365
+ const [k, ...v] = pair.split('=');
366
+ if (k && v.length > 0) {
367
+ variables[k.trim()] = v.join('=').trim();
370
368
  }
371
369
  }
370
+ }
372
371
 
372
+ if (mergedVarsDef.length > 0) {
373
373
  if (!options.yes) {
374
374
  for (const v of mergedVarsDef) {
375
375
  if (!variables[v.name]) {
@@ -401,6 +401,12 @@ export async function init(
401
401
  }
402
402
  }
403
403
 
404
+ if (Object.keys(variables).length > 0) {
405
+ for (const [key, val] of Object.entries(variables)) {
406
+ variables[key] = substituteVariables(val, variables);
407
+ }
408
+ }
409
+
404
410
  // 1. Create structure (deep merge folders across all templates)
405
411
  let mergedFolders: FolderNode[] = [];
406
412
  for (const { template } of loadedTemplates) {
@@ -509,7 +515,7 @@ export async function init(
509
515
  }
510
516
 
511
517
  let fileContent = fs.readFileSync(srcPath, 'utf-8');
512
- if (mergedVarsDef.length > 0) {
518
+ if (Object.keys(variables).length > 0) {
513
519
  fileContent = substituteVariables(fileContent, variables);
514
520
  }
515
521
 
@@ -554,6 +560,17 @@ export async function init(
554
560
  for (const lt of loadedTemplates) {
555
561
  if (lt.template.post_config) {
556
562
  for (const t of lt.template.post_config) {
563
+ if (Object.keys(variables).length > 0) {
564
+ if (t.command) {
565
+ t.command = substituteVariables(t.command, variables);
566
+ }
567
+ if (t.description) {
568
+ t.description = substituteVariables(t.description, variables);
569
+ }
570
+ if (t.script) {
571
+ t.script = substituteVariables(t.script, variables);
572
+ }
573
+ }
557
574
  if (!t.type || t.type === lt.name) {
558
575
  const key = `${t.command || t.script || ''}|${t.description || ''}`;
559
576
  if (taskMap.has(key)) {
@@ -723,6 +740,9 @@ export async function init(
723
740
  }
724
741
 
725
742
  function createStructure(dirPath: string, folders: FolderNode[], dryRun: boolean = false, silent: boolean = false) {
743
+ if (!dryRun) {
744
+ fs.mkdirSync(dirPath, { recursive: true });
745
+ }
726
746
  for (const folder of folders) {
727
747
  const fullDirPath = path.join(dirPath, sanitizePath(folder.name));
728
748
 
package/src/postconfig.ts CHANGED
@@ -5,6 +5,7 @@ import { execSync } from 'child_process';
5
5
  import chalk from 'chalk';
6
6
  import inquirer from 'inquirer';
7
7
  import { PostConfigTask } from './config.js';
8
+ import { substituteVariables } from './substitute.js';
8
9
  import {
9
10
  isBlockedCommand,
10
11
  isDangerousCommand,
@@ -28,7 +29,8 @@ export async function runPostConfig(
28
29
  destPath: string,
29
30
  tasks: PostConfigTask[],
30
31
  projectType: string,
31
- options: PostConfigOptions = {}
32
+ options: PostConfigOptions = {},
33
+ variables: Record<string, string> = {}
32
34
  ): Promise<void> {
33
35
  if (options.skipPostConfig) return;
34
36
 
@@ -42,8 +44,15 @@ export async function runPostConfig(
42
44
  return;
43
45
  }
44
46
 
45
- // 1. Filter tasks by type
46
- const applicableTasks = tasks.filter(t => !t.type || t.type === projectType);
47
+ // 1. Filter tasks by type and substitute variables
48
+ const applicableTasks = tasks
49
+ .filter(t => !t.type || t.type === projectType)
50
+ .map(t => ({
51
+ ...t,
52
+ command: t.command && Object.keys(variables).length > 0 ? substituteVariables(t.command, variables) : t.command,
53
+ description: t.description && Object.keys(variables).length > 0 ? substituteVariables(t.description, variables) : t.description,
54
+ script: t.script && Object.keys(variables).length > 0 ? substituteVariables(t.script, variables) : t.script,
55
+ }));
47
56
 
48
57
  if (applicableTasks.length === 0) {
49
58
  return;
package/src/substitute.ts CHANGED
@@ -21,7 +21,7 @@ export function substituteVariables(
21
21
  // Keep expanding until no more placeholders remain or we hit the limit
22
22
  while (/\{\{[^}]+\}\}/.test(result) && iteration < maxIterations) {
23
23
  // Use a more complex regex that captures the full placeholder including spaces
24
- result = result.replace(/(\{\{\s*)(\w+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
24
+ result = result.replace(/(\{\{\s*)([a-zA-Z0-9_-]+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
25
25
  const val = variables[varName];
26
26
  // If variable not found, leave placeholder as-is with original spacing
27
27
  if (val === undefined) {
@@ -0,0 +1,321 @@
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-post-config-vars');
8
+ process.env.HOME = testHome;
9
+
10
+ import { init } from '../src/commands/initCommand.js';
11
+ import { saveConfig, PtConfig } from '../src/config.js';
12
+ import { runPostConfig } from '../src/postconfig.js';
13
+
14
+ function cleanup(...paths: string[]) {
15
+ for (const p of paths) {
16
+ if (fs.existsSync(p)) {
17
+ fs.rmSync(p, { recursive: true, force: true });
18
+ }
19
+ }
20
+ }
21
+
22
+ function setupTestConfig(templateName: string, template: any): PtConfig {
23
+ const config: PtConfig = {
24
+ version: '3.0',
25
+ templates: {
26
+ [templateName]: template
27
+ }
28
+ };
29
+ saveConfig(config);
30
+ return config;
31
+ }
32
+
33
+ test('post_config variable substitution: replaces variables in command, script, and description', async () => {
34
+ const projectDest = path.join(process.cwd(), 'test-post-config-vars-project');
35
+ const templateRoot = path.join(process.cwd(), 'test-post-config-vars-root');
36
+ cleanup(projectDest, templateRoot, testHome);
37
+
38
+ fs.mkdirSync(templateRoot, { recursive: true });
39
+
40
+ setupTestConfig('memory-field-tpl', {
41
+ description: 'Memory field template',
42
+ templateRoot: templateRoot,
43
+ folders: [{ name: 'memories', info: '' }],
44
+ variables: [
45
+ { name: 'MEMORY_NAME', prompt: 'Memory Name:', required: true },
46
+ { name: 'MEMORY_DIR', prompt: 'Memory Dir:', default: 'memories' }
47
+ ],
48
+ post_config: [
49
+ {
50
+ command: 'echo "Creating {{ MEMORY_NAME }} in {{ MEMORY_DIR }}" > result.txt',
51
+ description: 'Create {{ MEMORY_NAME }}'
52
+ }
53
+ ]
54
+ });
55
+
56
+ await init('memory-field-tpl', projectDest, {
57
+ yes: true,
58
+ vars: 'MEMORY_NAME=test-agent,MEMORY_DIR=custom-memories'
59
+ });
60
+
61
+ // Verify post_config.sh and post_config.bat were written with substituted variables
62
+ const shContent = fs.readFileSync(path.join(projectDest, 'post_config.sh'), 'utf-8');
63
+ assert.ok(
64
+ shContent.includes('echo "Creating test-agent in custom-memories" > result.txt'),
65
+ `post_config.sh should have substituted command, got:\n${shContent}`
66
+ );
67
+ assert.ok(
68
+ shContent.includes('echo "Running: Create test-agent"'),
69
+ `post_config.sh should have substituted description, got:\n${shContent}`
70
+ );
71
+ assert.ok(!shContent.includes('{{ MEMORY_NAME }}'), 'post_config.sh should not contain {{ MEMORY_NAME }}');
72
+ assert.ok(!shContent.includes('{{ MEMORY_DIR }}'), 'post_config.sh should not contain {{ MEMORY_DIR }}');
73
+
74
+ const batContent = fs.readFileSync(path.join(projectDest, 'post_config.bat'), 'utf-8');
75
+ assert.ok(
76
+ batContent.includes('echo "Creating test-agent in custom-memories" > result.txt'),
77
+ `post_config.bat should have substituted command, got:\n${batContent}`
78
+ );
79
+ assert.ok(
80
+ batContent.includes('echo Running: Create test-agent'),
81
+ `post_config.bat should have substituted description, got:\n${batContent}`
82
+ );
83
+
84
+ // Verify that the task actually executed and produced the output with substituted vars
85
+ const resultTxt = fs.readFileSync(path.join(projectDest, 'result.txt'), 'utf-8');
86
+ assert.strictEqual(resultTxt.trim(), 'Creating test-agent in custom-memories');
87
+
88
+ cleanup(projectDest, templateRoot, testHome);
89
+ });
90
+
91
+ test('post_config variable substitution: variables from .env in parent directory', async () => {
92
+ const parentDir = path.join(process.cwd(), 'test-post-config-env-parent');
93
+ const projectDest = path.join(parentDir, 'test-post-config-env-project');
94
+ const templateRoot = path.join(process.cwd(), 'test-post-config-env-root');
95
+ cleanup(projectDest, templateRoot, parentDir, testHome);
96
+
97
+ fs.mkdirSync(templateRoot, { recursive: true });
98
+ fs.mkdirSync(parentDir, { recursive: true });
99
+ fs.writeFileSync(
100
+ path.join(parentDir, '.env'),
101
+ 'PROJECT_NAME=alpha\nMEMORY_DIR=agent_memories\n'
102
+ );
103
+
104
+ setupTestConfig('env-post-config-tpl', {
105
+ description: 'Template with env vars in post_config',
106
+ templateRoot: templateRoot,
107
+ folders: [],
108
+ variables: [
109
+ { name: 'PROJECT_NAME', prompt: 'Project name:' },
110
+ { name: 'MEMORY_DIR', prompt: 'Memory dir:', default: 'memories' }
111
+ ],
112
+ post_config: [
113
+ {
114
+ command: 'echo "name={{ PROJECT_NAME }} dir={{ MEMORY_DIR }}" > env_result.txt',
115
+ description: 'Initialize {{ PROJECT_NAME }}'
116
+ }
117
+ ]
118
+ });
119
+
120
+ await init('env-post-config-tpl', projectDest, {
121
+ yes: true
122
+ });
123
+
124
+ const shContent = fs.readFileSync(path.join(projectDest, 'post_config.sh'), 'utf-8');
125
+ assert.ok(shContent.includes('name=alpha dir=agent_memories'));
126
+
127
+ const envResult = fs.readFileSync(path.join(projectDest, 'env_result.txt'), 'utf-8');
128
+ assert.strictEqual(envResult.trim(), 'name=alpha dir=agent_memories');
129
+
130
+ cleanup(projectDest, templateRoot, parentDir, testHome);
131
+ });
132
+
133
+ test('post_config variable substitution: nested variable expansion in post_config', async () => {
134
+ const parentDir = path.join(process.cwd(), 'test-post-config-nested-parent');
135
+ const projectDest = path.join(parentDir, 'test-post-config-nested-project');
136
+ const templateRoot = path.join(process.cwd(), 'test-post-config-nested-root');
137
+ cleanup(projectDest, templateRoot, parentDir, testHome);
138
+
139
+ fs.mkdirSync(templateRoot, { recursive: true });
140
+ fs.mkdirSync(parentDir, { recursive: true });
141
+ fs.writeFileSync(
142
+ path.join(parentDir, '.env'),
143
+ `prefix='rst_{{ project }}'\nproject=deep_nested\n`
144
+ );
145
+
146
+ setupTestConfig('nested-post-config-tpl', {
147
+ description: 'Template with nested variable in post_config',
148
+ templateRoot: templateRoot,
149
+ folders: [],
150
+ variables: [
151
+ { name: 'prefix', prompt: 'Prefix:' },
152
+ { name: 'project', prompt: 'Project:' }
153
+ ],
154
+ post_config: [
155
+ {
156
+ command: 'echo "{{ prefix }}" > nested_result.txt',
157
+ description: 'Task for {{ prefix }}'
158
+ }
159
+ ]
160
+ });
161
+
162
+ await init('nested-post-config-tpl', projectDest, {
163
+ yes: true
164
+ });
165
+
166
+ const shContent = fs.readFileSync(path.join(projectDest, 'post_config.sh'), 'utf-8');
167
+ assert.ok(shContent.includes('echo "rst_deep_nested" > nested_result.txt'));
168
+ assert.ok(shContent.includes('echo "Running: Task for rst_deep_nested"'));
169
+
170
+ const nestedResult = fs.readFileSync(path.join(projectDest, 'nested_result.txt'), 'utf-8');
171
+ assert.strictEqual(nestedResult.trim(), 'rst_deep_nested');
172
+
173
+ cleanup(projectDest, templateRoot, parentDir, testHome);
174
+ });
175
+
176
+ test('post_config variable substitution: multi-template deduplication with variables', async () => {
177
+ const projectDest = path.join(process.cwd(), 'test-post-config-multi-project');
178
+ const templateRoot1 = path.join(process.cwd(), 'test-post-config-multi-root1');
179
+ const templateRoot2 = path.join(process.cwd(), 'test-post-config-multi-root2');
180
+ cleanup(projectDest, templateRoot1, templateRoot2, testHome);
181
+
182
+ fs.mkdirSync(templateRoot1, { recursive: true });
183
+ fs.mkdirSync(templateRoot2, { recursive: true });
184
+
185
+ const config: PtConfig = {
186
+ version: '3.0',
187
+ templates: {
188
+ tplA: {
189
+ description: 'Template A',
190
+ templateRoot: templateRoot1,
191
+ folders: [],
192
+ variables: [{ name: 'COMMON_DIR', prompt: 'Common dir:', default: 'shared' }],
193
+ post_config: [
194
+ {
195
+ command: 'echo "{{ COMMON_DIR }}" >> common.txt',
196
+ description: 'Common task'
197
+ }
198
+ ]
199
+ },
200
+ tplB: {
201
+ description: 'Template B',
202
+ templateRoot: templateRoot2,
203
+ folders: [],
204
+ variables: [{ name: 'COMMON_DIR', prompt: 'Common dir:', default: 'shared' }],
205
+ post_config: [
206
+ {
207
+ command: 'echo "{{ COMMON_DIR }}" >> common.txt',
208
+ description: 'Common task'
209
+ }
210
+ ]
211
+ }
212
+ }
213
+ };
214
+ saveConfig(config);
215
+
216
+ await init(['tplA', 'tplB', projectDest], {
217
+ yes: true,
218
+ vars: 'COMMON_DIR=my_shared_dir'
219
+ });
220
+
221
+ const shContent = fs.readFileSync(path.join(projectDest, 'post_config.sh'), 'utf-8');
222
+ // Should deduplicate identical substituted tasks
223
+ const occurrences = (shContent.match(/echo "my_shared_dir" >> common.txt/g) || []).length;
224
+ assert.strictEqual(occurrences, 1, 'Duplicate task after variable substitution should be deduplicated to 1');
225
+
226
+ cleanup(projectDest, templateRoot1, templateRoot2, testHome);
227
+ });
228
+
229
+ test('postconfig.ts: runPostConfig applies variable substitution', async () => {
230
+ const projectDest = path.join(process.cwd(), 'test-postconfig-fn-project');
231
+ cleanup(projectDest, testHome);
232
+ fs.mkdirSync(projectDest, { recursive: true });
233
+
234
+ const tasks = [
235
+ {
236
+ command: 'echo "{{ APP_ENV }}" > app.txt',
237
+ description: 'Configure {{ APP_ENV }}'
238
+ }
239
+ ];
240
+
241
+ await runPostConfig(
242
+ projectDest,
243
+ tasks,
244
+ 'my-app',
245
+ { yes: true },
246
+ { APP_ENV: 'staging' }
247
+ );
248
+
249
+ const appTxt = fs.readFileSync(path.join(projectDest, 'app.txt'), 'utf-8');
250
+ assert.strictEqual(appTxt.trim(), 'staging');
251
+
252
+ cleanup(projectDest, testHome);
253
+ });
254
+
255
+ test('post_config variable substitution: supports hyphenated variable names', async () => {
256
+ const projectDest = path.join(process.cwd(), 'test-post-config-hyphen-project');
257
+ const templateRoot = path.join(process.cwd(), 'test-post-config-hyphen-root');
258
+ cleanup(projectDest, templateRoot, testHome);
259
+
260
+ fs.mkdirSync(templateRoot, { recursive: true });
261
+
262
+ setupTestConfig('hyphen-var-tpl', {
263
+ description: 'Hyphen var template',
264
+ templateRoot: templateRoot,
265
+ folders: [],
266
+ variables: [{ name: 'memory-name', prompt: 'Memory Name:' }],
267
+ post_config: [
268
+ {
269
+ command: 'echo "{{ memory-name }}" > hyphen_out.txt',
270
+ description: 'Create {{ memory-name }}'
271
+ }
272
+ ]
273
+ });
274
+
275
+ await init('hyphen-var-tpl', projectDest, {
276
+ yes: true,
277
+ vars: 'memory-name=my-hyphen-mem'
278
+ });
279
+
280
+ const shContent = fs.readFileSync(path.join(projectDest, 'post_config.sh'), 'utf-8');
281
+ assert.ok(shContent.includes('echo "my-hyphen-mem" > hyphen_out.txt'));
282
+
283
+ const out = fs.readFileSync(path.join(projectDest, 'hyphen_out.txt'), 'utf-8');
284
+ assert.strictEqual(out.trim(), 'my-hyphen-mem');
285
+
286
+ cleanup(projectDest, templateRoot, testHome);
287
+ });
288
+
289
+ test('post_config variable substitution: --vars works even when template has no variables field', async () => {
290
+ const projectDest = path.join(process.cwd(), 'test-post-config-novars-project');
291
+ const templateRoot = path.join(process.cwd(), 'test-post-config-novars-root');
292
+ cleanup(projectDest, templateRoot, testHome);
293
+
294
+ fs.mkdirSync(templateRoot, { recursive: true });
295
+
296
+ setupTestConfig('novars-tpl', {
297
+ description: 'No variables field template',
298
+ templateRoot: templateRoot,
299
+ folders: [],
300
+ post_config: [
301
+ {
302
+ command: 'echo "hello {{ USER_VAR }}" > novars_out.txt',
303
+ description: 'Run {{ USER_VAR }}'
304
+ }
305
+ ]
306
+ });
307
+
308
+ await init('novars-tpl', projectDest, {
309
+ yes: true,
310
+ vars: 'USER_VAR=world'
311
+ });
312
+
313
+ const shContent = fs.readFileSync(path.join(projectDest, 'post_config.sh'), 'utf-8');
314
+ assert.ok(shContent.includes('echo "hello world" > novars_out.txt'));
315
+
316
+ const out = fs.readFileSync(path.join(projectDest, 'novars_out.txt'), 'utf-8');
317
+ assert.strictEqual(out.trim(), 'hello world');
318
+
319
+ cleanup(projectDest, templateRoot, testHome);
320
+ });
321
+