@garyr/pt-cli 1.3.0 → 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
@@ -67,9 +67,28 @@ _pt_completions() {
67
67
  if [[ "$cur" == -* ]]; then
68
68
  COMPREPLY=( $(compgen -W "-f --file --skip-post-config --dry-run -y --yes --vars --collision --json -h --help" -- "$cur") )
69
69
  elif [[ $cword -ge 2 ]]; then
70
+ # Try template name completion first
70
71
  local templates
71
72
  templates=$(pt completion --templates 2>/dev/null)
72
- COMPREPLY=( $(compgen -W "$templates" -- "$cur") )
73
+ local template_matches
74
+ template_matches=$(compgen -W "$templates" -- "$cur")
75
+
76
+ # If we're at a position where destination could be (cword >= 2 for first arg after init,
77
+ # or cword >= 3 with templates already specified),
78
+ # also try directory completion. This handles cases like "pt init Base SPA PARK<TAB>"
79
+ local dir_matches
80
+ if [[ $cword -ge 2 ]]; then
81
+ dir_matches=$(compgen -d -- "$cur")
82
+ fi
83
+
84
+ # Combine matches - template matches first, then directory matches
85
+ COMPREPLY=()
86
+ if [[ -n "$template_matches" ]]; then
87
+ COMPREPLY=( $template_matches )
88
+ fi
89
+ if [[ -n "$dir_matches" ]]; then
90
+ COMPREPLY+=( $dir_matches )
91
+ fi
73
92
  fi
74
93
  ;;
75
94
  config)
@@ -192,16 +211,16 @@ _pt() {
192
211
  '2:sourcePath:_files -/'
193
212
  ;;
194
213
  init)
195
- _arguments \\
196
- '(-f --file)'{-f,--file}'[Initialize directly from a JSON template file]:file:_files' \\
197
- '--skip-post-config[Skip running post-config tasks]' \\
198
- '--dry-run[Show what would be created without making changes]' \\
199
- '(-y --yes)'{-y,--yes}'[Automatically answer yes to prompts]' \\
200
- '--vars=[Comma-separated key=value variables]:variables:' \\
201
- '--collision=[File collision resolution strategy]:mode:(overwrite newest)' \\
202
- '--json[Output result as JSON]' \\
203
- '(-h --help)'{-h,--help}'[display help for command]' \\
204
- '*:templates:_pt_templates'
214
+ _arguments \
215
+ '(-f --file)'{-f,--file}'[Initialize directly from a JSON template file]:file:_files' \
216
+ '--skip-post-config[Skip running post-config tasks]' \
217
+ '--dry-run[Show what would be created without making changes]' \
218
+ '(-y --yes)'{-y,--yes}'[Automatically answer yes to prompts]' \
219
+ '--vars=[Comma-separated key=value variables]:variables:' \
220
+ '--collision=[File collision resolution strategy]:mode:(overwrite newest)' \
221
+ '--json[Output result as JSON]' \
222
+ '(-h --help)'{-h,--help}'[display help for command]' \
223
+ '*: :(_pt_templates _directories)'
205
224
  ;;
206
225
  config)
207
226
  _arguments \\
@@ -322,6 +341,7 @@ complete -c pt -n '__fish_pt_using_command update' -l no-diff -d 'Disable additi
322
341
 
323
342
  # init
324
343
  complete -c pt -n '__fish_pt_using_command init' -a '(__fish_pt_templates)' -d 'Template name'
344
+ complete -c pt -n '__fish_pt_using_command init' -F -d 'Target directory' --wraps=pt --condition=__fish_pt_init_dir
325
345
  complete -c pt -n '__fish_pt_using_command init' -s f -l file -d 'Initialize directly from a JSON template file without adding it to local config'
326
346
  complete -c pt -n '__fish_pt_using_command init' -l skip-post-config -d 'Skip running post-config tasks'
327
347
  complete -c pt -n '__fish_pt_using_command init' -l dry-run -d 'Show what would be created without making changes'
@@ -330,6 +350,20 @@ complete -c pt -n '__fish_pt_using_command init' -l vars -d 'Comma-separated key
330
350
  complete -c pt -n '__fish_pt_using_command init' -l collision -a 'overwrite newest' -d 'File collision resolution strategy'
331
351
  complete -c pt -n '__fish_pt_using_command init' -l json -d 'Output result as JSON'
332
352
 
353
+ # Helper function for init directory completion (only when last positional arg looks like a path)
354
+ function __fish_pt_init_dir
355
+ set -l cmd (commandline -opc)
356
+ # Count non-flag positional arguments after 'init'
357
+ set -l args (string match -r '(^[^ ]+ )?init( .+)?' <<< "$cmd")
358
+ # Simple approach: if the current token contains / or starts with ~ or ., complete as directory
359
+ set -l cur (commandline -ct)
360
+ if string match -q '*/' "$cur"; or string match -q '~*' "$cur"; or string match -q '.*' "$cur"
361
+ return 0
362
+ else
363
+ return 1
364
+ end
365
+ end
366
+
333
367
  # config
334
368
  complete -c pt -n '__fish_pt_using_command config' -a '(__fish_pt_templates)' -d 'Template name'
335
369
  complete -c pt -n '__fish_pt_using_command config' -l json -d 'Output config or specific template as JSON'
@@ -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.0",
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": {
@@ -68,9 +68,28 @@ _pt_completions() {
68
68
  if [[ "$cur" == -* ]]; then
69
69
  COMPREPLY=( $(compgen -W "-f --file --skip-post-config --dry-run -y --yes --vars --collision --json -h --help" -- "$cur") )
70
70
  elif [[ $cword -ge 2 ]]; then
71
+ # Try template name completion first
71
72
  local templates
72
73
  templates=$(pt completion --templates 2>/dev/null)
73
- COMPREPLY=( $(compgen -W "$templates" -- "$cur") )
74
+ local template_matches
75
+ template_matches=$(compgen -W "$templates" -- "$cur")
76
+
77
+ # If we're at a position where destination could be (cword >= 2 for first arg after init,
78
+ # or cword >= 3 with templates already specified),
79
+ # also try directory completion. This handles cases like "pt init Base SPA PARK<TAB>"
80
+ local dir_matches
81
+ if [[ $cword -ge 2 ]]; then
82
+ dir_matches=$(compgen -d -- "$cur")
83
+ fi
84
+
85
+ # Combine matches - template matches first, then directory matches
86
+ COMPREPLY=()
87
+ if [[ -n "$template_matches" ]]; then
88
+ COMPREPLY=( $template_matches )
89
+ fi
90
+ if [[ -n "$dir_matches" ]]; then
91
+ COMPREPLY+=( $dir_matches )
92
+ fi
74
93
  fi
75
94
  ;;
76
95
  config)
@@ -194,16 +213,16 @@ _pt() {
194
213
  '2:sourcePath:_files -/'
195
214
  ;;
196
215
  init)
197
- _arguments \\
198
- '(-f --file)'{-f,--file}'[Initialize directly from a JSON template file]:file:_files' \\
199
- '--skip-post-config[Skip running post-config tasks]' \\
200
- '--dry-run[Show what would be created without making changes]' \\
201
- '(-y --yes)'{-y,--yes}'[Automatically answer yes to prompts]' \\
202
- '--vars=[Comma-separated key=value variables]:variables:' \\
203
- '--collision=[File collision resolution strategy]:mode:(overwrite newest)' \\
204
- '--json[Output result as JSON]' \\
205
- '(-h --help)'{-h,--help}'[display help for command]' \\
206
- '*:templates:_pt_templates'
216
+ _arguments \
217
+ '(-f --file)'{-f,--file}'[Initialize directly from a JSON template file]:file:_files' \
218
+ '--skip-post-config[Skip running post-config tasks]' \
219
+ '--dry-run[Show what would be created without making changes]' \
220
+ '(-y --yes)'{-y,--yes}'[Automatically answer yes to prompts]' \
221
+ '--vars=[Comma-separated key=value variables]:variables:' \
222
+ '--collision=[File collision resolution strategy]:mode:(overwrite newest)' \
223
+ '--json[Output result as JSON]' \
224
+ '(-h --help)'{-h,--help}'[display help for command]' \
225
+ '*: :(_pt_templates _directories)'
207
226
  ;;
208
227
  config)
209
228
  _arguments \\
@@ -325,6 +344,7 @@ complete -c pt -n '__fish_pt_using_command update' -l no-diff -d 'Disable additi
325
344
 
326
345
  # init
327
346
  complete -c pt -n '__fish_pt_using_command init' -a '(__fish_pt_templates)' -d 'Template name'
347
+ complete -c pt -n '__fish_pt_using_command init' -F -d 'Target directory' --wraps=pt --condition=__fish_pt_init_dir
328
348
  complete -c pt -n '__fish_pt_using_command init' -s f -l file -d 'Initialize directly from a JSON template file without adding it to local config'
329
349
  complete -c pt -n '__fish_pt_using_command init' -l skip-post-config -d 'Skip running post-config tasks'
330
350
  complete -c pt -n '__fish_pt_using_command init' -l dry-run -d 'Show what would be created without making changes'
@@ -333,6 +353,20 @@ complete -c pt -n '__fish_pt_using_command init' -l vars -d 'Comma-separated key
333
353
  complete -c pt -n '__fish_pt_using_command init' -l collision -a 'overwrite newest' -d 'File collision resolution strategy'
334
354
  complete -c pt -n '__fish_pt_using_command init' -l json -d 'Output result as JSON'
335
355
 
356
+ # Helper function for init directory completion (only when last positional arg looks like a path)
357
+ function __fish_pt_init_dir
358
+ set -l cmd (commandline -opc)
359
+ # Count non-flag positional arguments after 'init'
360
+ set -l args (string match -r '(^[^ ]+ )?init( .+)?' <<< "$cmd")
361
+ # Simple approach: if the current token contains / or starts with ~ or ., complete as directory
362
+ set -l cur (commandline -ct)
363
+ if string match -q '*/' "$cur"; or string match -q '~*' "$cur"; or string match -q '.*' "$cur"
364
+ return 0
365
+ else
366
+ return 1
367
+ end
368
+ end
369
+
336
370
  # config
337
371
  complete -c pt -n '__fish_pt_using_command config' -a '(__fish_pt_templates)' -d 'Template name'
338
372
  complete -c pt -n '__fish_pt_using_command config' -l json -d 'Output config or specific template as JSON'
@@ -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
+