@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 +15 -0
- package/dist/commands/initCommand.js +35 -16
- package/dist/postconfig.js +11 -3
- package/dist/substitute.js +1 -1
- package/package.json +1 -1
- package/src/commands/initCommand.ts +36 -16
- package/src/postconfig.ts +12 -3
- package/src/substitute.ts +1 -1
- package/tests/post-config-variables.test.ts +321 -0
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 (
|
|
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) {
|
package/dist/postconfig.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/substitute.js
CHANGED
|
@@ -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*)(
|
|
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
|
@@ -349,27 +349,27 @@ export async function init(
|
|
|
349
349
|
const mergedVarsDef = mergeVariables(loadedTemplates);
|
|
350
350
|
let variables: Record<string, string> = {};
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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 (
|
|
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
|
|
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*)(
|
|
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
|
+
|