@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/README.md +3 -1
- package/dist/commands/addCommand.js +98 -8
- package/dist/commands/configCommand.js +11 -4
- package/dist/commands/initCommand.js +65 -0
- package/dist/commands/learnCommand.js +78 -5
- package/dist/config.js +12 -2
- package/dist/index.js +6 -3
- package/dist/substitute.js +25 -4
- package/doc/configuration.md +70 -8
- package/doc/usage.md +71 -0
- package/doc/variable_substitution_example.md +64 -0
- package/package.json +1 -1
- package/src/commands/addCommand.ts +109 -8
- package/src/commands/configCommand.ts +11 -4
- package/src/commands/initCommand.ts +77 -0
- package/src/commands/learnCommand.ts +83 -8
- package/src/config.ts +14 -2
- package/src/index.ts +6 -3
- package/src/substitute.ts +29 -4
- package/tests/env-scanning.test.ts +330 -0
- package/tests/final-rst-verification.test.ts +173 -0
- package/tests/nested-variable-expansion.test.ts +242 -0
- package/tests/rst-env-example.test.ts +208 -0
- package/tests/substitute.test.ts +14 -6
package/README.md
CHANGED
|
@@ -45,13 +45,15 @@ graph LR
|
|
|
45
45
|
- Initialize new projects from learned templates
|
|
46
46
|
- Define template variables for dynamic file customization
|
|
47
47
|
- **Automatic Variable Detection:** Scans text files for `{{ var }}` syntax during `learn`/`update`
|
|
48
|
+
- **Nested Variable Expansion (v0.36.0+):** Variables can contain other variable placeholders that are resolved iteratively
|
|
49
|
+
- **Parent Directory `.env` File Scanning:** Automatically scans parent directories for `.env` files and uses their values as defaults
|
|
48
50
|
- Auto-detect and suggest post-config setup tasks
|
|
49
51
|
- Configure global post-config tasks in `~/.pt/config.yaml` (apply to all projects)
|
|
50
52
|
- Baked-in defaults for common project types (javascript, python, godot, etc.)
|
|
51
53
|
- Share templates or use as an API with JSON export/import
|
|
52
54
|
- **Direct JSON scaffolding:** Initialize projects from a JSON file without registering in `config.yaml`
|
|
53
55
|
- **Portable template configs:** `.pt-template.json` files make shared directories fully self-describing
|
|
54
|
-
- Fully supports non-interactive mode (`--yes`, `--vars`) for AI agent automation
|
|
56
|
+
- Fully supports non-interactive mode (`--yes`, `--vars`, `--name`, `--desc`) for AI agent automation
|
|
55
57
|
|
|
56
58
|
## Quick Start
|
|
57
59
|
|
|
@@ -2,24 +2,114 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { loadConfig, saveConfig } from '../config.js';
|
|
5
|
+
/**
|
|
6
|
+
* Validate JSON file exists and contains valid JSON
|
|
7
|
+
*/
|
|
8
|
+
function validateJsonFile(filePath) {
|
|
9
|
+
try {
|
|
10
|
+
// Check if file exists
|
|
11
|
+
if (!fs.existsSync(filePath)) {
|
|
12
|
+
return {
|
|
13
|
+
valid: false,
|
|
14
|
+
error: `File not found: ${filePath}`
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
// Check file size (reasonable limit to prevent reading huge files)
|
|
18
|
+
const stats = fs.statSync(filePath);
|
|
19
|
+
if (stats.size > 10 * 1024 * 1024) { // 10MB limit
|
|
20
|
+
return {
|
|
21
|
+
valid: false,
|
|
22
|
+
error: `File too large (${stats.size} bytes). Maximum size is 10MB.`
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Read and parse JSON
|
|
26
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
27
|
+
if (!content.trim()) {
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
error: 'File is empty'
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const data = JSON.parse(content);
|
|
34
|
+
return { valid: true, data };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
const error = e;
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: `JSON parse error: ${error.message}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate template structure
|
|
46
|
+
*/
|
|
47
|
+
function validateTemplateStructure(data) {
|
|
48
|
+
if (!data || typeof data !== 'object') {
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
error: 'Template data must be a JSON object'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Basic structure validation
|
|
55
|
+
if (data.description && typeof data.description !== 'string') {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: 'Template description must be a string'
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (data.variables && Array.isArray(data.variables)) {
|
|
62
|
+
for (let i = 0; i < data.variables.length; i++) {
|
|
63
|
+
const v = data.variables[i];
|
|
64
|
+
if (!v.name || typeof v.name !== 'string') {
|
|
65
|
+
return {
|
|
66
|
+
valid: false,
|
|
67
|
+
error: `Variable at index ${i} must have a string 'name' field`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { valid: true };
|
|
73
|
+
}
|
|
5
74
|
export function addCommand(name, jsonStr, options = {}) {
|
|
6
75
|
const config = loadConfig();
|
|
76
|
+
// Determine if we're reading from file or string
|
|
77
|
+
const isFile = !!options.file;
|
|
78
|
+
let data;
|
|
7
79
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
80
|
+
if (isFile) {
|
|
81
|
+
// Validate file first
|
|
10
82
|
const filePath = path.resolve(options.file);
|
|
11
|
-
|
|
83
|
+
const validation = validateJsonFile(filePath);
|
|
84
|
+
if (!validation.valid) {
|
|
85
|
+
console.error(chalk.red(`Error: ${validation.error}`));
|
|
86
|
+
console.error(chalk.gray(`File: ${filePath}`));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
data = validation.data;
|
|
12
90
|
}
|
|
13
91
|
else if (jsonStr) {
|
|
14
|
-
|
|
92
|
+
// Parse JSON string directly
|
|
93
|
+
try {
|
|
94
|
+
data = JSON.parse(jsonStr);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
const error = e;
|
|
98
|
+
console.error(chalk.red(`Error: Invalid JSON string - ${error.message}`));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
15
101
|
}
|
|
16
102
|
else {
|
|
17
103
|
console.error('Error: Either a JSON string or --file <path> must be provided.');
|
|
18
104
|
process.exit(1);
|
|
19
105
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
106
|
+
// Validate template structure
|
|
107
|
+
const structureValidation = validateTemplateStructure(data);
|
|
108
|
+
if (!structureValidation.valid) {
|
|
109
|
+
console.error(chalk.red(`Error: Invalid template structure - ${structureValidation.error}`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
// Check for full config object
|
|
23
113
|
if (data && data.templates && typeof data.templates === 'object') {
|
|
24
114
|
console.error(chalk.red('Error: The provided JSON appears to be a full configuration file, not a single template.'));
|
|
25
115
|
console.error(chalk.gray('If you want to import a specific template from it, extract that template object first.'));
|
|
@@ -31,7 +121,7 @@ export function addCommand(name, jsonStr, options = {}) {
|
|
|
31
121
|
}
|
|
32
122
|
catch (e) {
|
|
33
123
|
const error = e;
|
|
34
|
-
console.error(chalk.red(`Failed to
|
|
124
|
+
console.error(chalk.red(`Failed to process template: ${error.message}`));
|
|
35
125
|
process.exit(1);
|
|
36
126
|
}
|
|
37
127
|
}
|
|
@@ -9,15 +9,22 @@ export function configCommand(templateName, options = {}) {
|
|
|
9
9
|
name: templateName,
|
|
10
10
|
...config.templates[templateName]
|
|
11
11
|
};
|
|
12
|
-
|
|
12
|
+
// Safely drain stdout before allowing the process to close
|
|
13
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
|
|
14
|
+
process.exit(0);
|
|
15
|
+
});
|
|
13
16
|
}
|
|
14
17
|
else {
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
process.stderr.write(chalk.red(`Error: Template "${templateName}" not found.\n`), () => {
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
else {
|
|
20
|
-
|
|
24
|
+
// Safely drain the entire global config payload
|
|
25
|
+
process.stdout.write(JSON.stringify(config, null, 2) + '\n', () => {
|
|
26
|
+
process.exit(0);
|
|
27
|
+
});
|
|
21
28
|
}
|
|
22
29
|
return;
|
|
23
30
|
}
|
|
@@ -5,6 +5,61 @@ import { loadConfig, sanitizePath } from '../config.js';
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { processCopyFiles } from '../substitute.js';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
+
/**
|
|
9
|
+
* Scan parent directories for .env files and parse their variables.
|
|
10
|
+
* Returns a map of variable names to their values, supporting:
|
|
11
|
+
* - KEY=VALUE format
|
|
12
|
+
* - KEY="VALUE with spaces" format
|
|
13
|
+
* - KEY='VALUE with spaces' format
|
|
14
|
+
* - Comments (lines starting with #)
|
|
15
|
+
* - Empty lines
|
|
16
|
+
*/
|
|
17
|
+
function scanEnvForVariables(targetPath) {
|
|
18
|
+
const envVars = {};
|
|
19
|
+
let currentDir = path.resolve(targetPath);
|
|
20
|
+
// Scan up to 5 parent directories for .env files
|
|
21
|
+
const maxDepth = 5;
|
|
22
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
23
|
+
const envPath = path.join(currentDir, '.env');
|
|
24
|
+
if (fs.existsSync(envPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
// Skip empty lines and comments
|
|
31
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Match KEY=VALUE patterns
|
|
35
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
const key = match[1];
|
|
38
|
+
let value = match[2];
|
|
39
|
+
// Remove surrounding quotes if present
|
|
40
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
41
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
42
|
+
value = value.slice(1, -1);
|
|
43
|
+
}
|
|
44
|
+
envVars[key] = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
// Silently skip unreadable .env files
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Move to parent directory
|
|
54
|
+
const parentDir = path.dirname(currentDir);
|
|
55
|
+
if (parentDir === currentDir) {
|
|
56
|
+
// Reached filesystem root
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
currentDir = parentDir;
|
|
60
|
+
}
|
|
61
|
+
return envVars;
|
|
62
|
+
}
|
|
8
63
|
export async function init(targetName, destPath, options = {}) {
|
|
9
64
|
const config = loadConfig();
|
|
10
65
|
let typeName = targetName;
|
|
@@ -86,6 +141,16 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
86
141
|
// Handle Variables
|
|
87
142
|
let variables = {};
|
|
88
143
|
if (template.variables && template.variables.length > 0) {
|
|
144
|
+
// Scan parent directories for .env files and pre-fill variables
|
|
145
|
+
const envVars = scanEnvForVariables(resolvedDest);
|
|
146
|
+
// Merge .env variables into variables (with lower priority than --vars)
|
|
147
|
+
if (Object.keys(envVars).length > 0) {
|
|
148
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
149
|
+
if (!variables[key]) {
|
|
150
|
+
variables[key] = value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
89
154
|
if (options.vars) {
|
|
90
155
|
// Parse --vars "key=val,key2=val2"
|
|
91
156
|
const pairs = options.vars.split(',').map((p) => p.trim());
|
|
@@ -9,7 +9,16 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
9
9
|
// Phase 1: Remote Check
|
|
10
10
|
if (sourcePath.startsWith('http')) {
|
|
11
11
|
console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
|
|
12
|
-
|
|
12
|
+
try {
|
|
13
|
+
resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
if (err.message === 'Download cancelled by user due to untrusted source') {
|
|
17
|
+
console.log(chalk.yellow('Download cancelled. Exiting.'));
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
13
22
|
}
|
|
14
23
|
else {
|
|
15
24
|
resolvedPath = path.resolve(sourcePath);
|
|
@@ -65,6 +74,8 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
else {
|
|
77
|
+
// Track whether name came from .info.md or JSON (not user-provided)
|
|
78
|
+
const nameFromSource = !options.name && (fileTemplateConfig.name || infoName);
|
|
68
79
|
if (options.name) {
|
|
69
80
|
targetName = options.name;
|
|
70
81
|
}
|
|
@@ -92,6 +103,34 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
92
103
|
targetName = newName;
|
|
93
104
|
}
|
|
94
105
|
}
|
|
106
|
+
// If name came from .info.md or JSON, prompt to confirm/edit
|
|
107
|
+
// This prevents accidental overwrites when creating new templates based on existing ones
|
|
108
|
+
if (nameFromSource && !options.json) {
|
|
109
|
+
if (options.yes) {
|
|
110
|
+
// In --yes mode, keep the auto-detected name but warn
|
|
111
|
+
const existingNames = getTemplateNames(config);
|
|
112
|
+
if (existingNames.includes(targetName)) {
|
|
113
|
+
console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const { confirmName } = await inquirer.prompt({
|
|
118
|
+
type: 'confirm',
|
|
119
|
+
name: 'confirmName',
|
|
120
|
+
message: `Use "${targetName}" as the template name?`,
|
|
121
|
+
default: true
|
|
122
|
+
});
|
|
123
|
+
if (!confirmName) {
|
|
124
|
+
const { newName } = await inquirer.prompt({
|
|
125
|
+
type: 'input',
|
|
126
|
+
name: 'newName',
|
|
127
|
+
message: 'Name this template:',
|
|
128
|
+
default: targetName
|
|
129
|
+
});
|
|
130
|
+
targetName = newName;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
95
134
|
}
|
|
96
135
|
let description = '';
|
|
97
136
|
if (options.desc) {
|
|
@@ -124,6 +163,8 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
124
163
|
}
|
|
125
164
|
}
|
|
126
165
|
else {
|
|
166
|
+
// Track whether description came from .info.md or JSON (not user-provided)
|
|
167
|
+
const descFromSource = !options.desc && (fileTemplateConfig.description || infoDesc);
|
|
127
168
|
if (fileTemplateConfig.description) {
|
|
128
169
|
description = fileTemplateConfig.description;
|
|
129
170
|
if (!options.json)
|
|
@@ -146,6 +187,34 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
146
187
|
});
|
|
147
188
|
description = newDesc;
|
|
148
189
|
}
|
|
190
|
+
// If description came from .info.md or JSON, prompt to confirm/edit
|
|
191
|
+
// This prevents accidental overwrites when creating new templates based on existing ones
|
|
192
|
+
if (descFromSource && !options.json) {
|
|
193
|
+
if (options.yes) {
|
|
194
|
+
// In --yes mode, keep the auto-detected description but warn
|
|
195
|
+
const existingNames = getTemplateNames(config);
|
|
196
|
+
if (existingNames.includes(targetName)) {
|
|
197
|
+
console.warn(chalk.yellow(`⚠ Warning: "${targetName}" already exists. Using this name will overwrite the existing template.`));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
const { confirmDesc } = await inquirer.prompt({
|
|
202
|
+
type: 'confirm',
|
|
203
|
+
name: 'confirmDesc',
|
|
204
|
+
message: `Use "${description}" as the template description?`,
|
|
205
|
+
default: true
|
|
206
|
+
});
|
|
207
|
+
if (!confirmDesc) {
|
|
208
|
+
const { newDesc } = await inquirer.prompt({
|
|
209
|
+
type: 'input',
|
|
210
|
+
name: 'newDesc',
|
|
211
|
+
message: 'Purpose/Description of this template:',
|
|
212
|
+
default: description
|
|
213
|
+
});
|
|
214
|
+
description = newDesc;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
149
218
|
}
|
|
150
219
|
const cliIgnore = options.ignore ? options.ignore.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
151
220
|
const ignorePatterns = [...(config.ignore || []), ...cliIgnore];
|
|
@@ -466,7 +535,11 @@ export async function learn(sourcePath, updateTemplate = null, options = {}) {
|
|
|
466
535
|
name: targetName,
|
|
467
536
|
...templateConfig
|
|
468
537
|
};
|
|
469
|
-
|
|
538
|
+
// Force the application to wait until every single byte of this JSON string
|
|
539
|
+
// safely clears the operating system's pipe buffer before letting the process die.
|
|
540
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n', () => {
|
|
541
|
+
process.exit(0);
|
|
542
|
+
});
|
|
470
543
|
return;
|
|
471
544
|
}
|
|
472
545
|
config.templates[targetName] = templateConfig;
|
|
@@ -508,10 +581,10 @@ function extractStructure(dirPath, rootPath, ignorePatterns) {
|
|
|
508
581
|
let info = "";
|
|
509
582
|
const gitkeepPath = path.join(fullPath, '.gitkeep.md');
|
|
510
583
|
const infoPath = path.join(fullPath, '.info.md');
|
|
511
|
-
if (fs.existsSync(
|
|
512
|
-
info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
|
|
513
|
-
else if (fs.existsSync(infoPath))
|
|
584
|
+
if (fs.existsSync(infoPath))
|
|
514
585
|
info = fs.readFileSync(infoPath, 'utf-8').trim();
|
|
586
|
+
else if (fs.existsSync(gitkeepPath))
|
|
587
|
+
info = fs.readFileSync(gitkeepPath, 'utf-8').trim();
|
|
515
588
|
nodes.push({ name: entry.name, info: info, children: children });
|
|
516
589
|
}
|
|
517
590
|
}
|
package/dist/config.js
CHANGED
|
@@ -84,12 +84,22 @@ export function loadConfig() {
|
|
|
84
84
|
catch (err) {
|
|
85
85
|
const error = err;
|
|
86
86
|
console.error(chalk.red(`\nError loading config: ${error.message}`));
|
|
87
|
-
// If we have a backup, maybe suggest using it
|
|
88
87
|
const backupPath = getConfigPath() + '.bak';
|
|
89
88
|
if (fs.existsSync(backupPath)) {
|
|
90
89
|
console.error(chalk.yellow(`A backup exists at ${backupPath}. You may want to restore it.`));
|
|
91
90
|
}
|
|
92
|
-
|
|
91
|
+
// Allow the event loop to flush console streams out to Godot before dying
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}, 5);
|
|
95
|
+
// 👇 Add this return statement to satisfy the TypeScript compiler
|
|
96
|
+
// The application will terminate before this empty config can be used.
|
|
97
|
+
return {
|
|
98
|
+
version: '3.0',
|
|
99
|
+
templates: {},
|
|
100
|
+
default_post_config: [],
|
|
101
|
+
variables: []
|
|
102
|
+
};
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
export function normalizeVariable(v) {
|
package/dist/index.js
CHANGED
|
@@ -32,15 +32,18 @@ program
|
|
|
32
32
|
}
|
|
33
33
|
catch (err) {
|
|
34
34
|
if (options.json) {
|
|
35
|
-
|
|
35
|
+
// Fix: Ensure the error JSON payload isn't truncated before exiting
|
|
36
|
+
process.stdout.write(JSON.stringify({
|
|
36
37
|
type: 'error',
|
|
37
38
|
message: err.message || String(err)
|
|
38
|
-
}))
|
|
39
|
+
}) + '\n', () => {
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
39
42
|
}
|
|
40
43
|
else {
|
|
41
44
|
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
45
|
+
process.exit(1);
|
|
42
46
|
}
|
|
43
|
-
process.exit(1);
|
|
44
47
|
}
|
|
45
48
|
});
|
|
46
49
|
program
|
package/dist/substitute.js
CHANGED
|
@@ -4,11 +4,32 @@ import path from 'path';
|
|
|
4
4
|
import { sanitizePath } from './config.js';
|
|
5
5
|
/**
|
|
6
6
|
* Replaces all {{var}} patterns in the content with values from the variables object.
|
|
7
|
+
* Supports nested variable expansion - if a variable's value contains {{other_var}},
|
|
8
|
+
* it will be expanded iteratively until no more placeholders remain or maxIterations is reached.
|
|
7
9
|
*/
|
|
8
|
-
export function substituteVariables(content, variables) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
export function substituteVariables(content, variables, maxIterations = 10) {
|
|
11
|
+
let result = content;
|
|
12
|
+
let iteration = 0;
|
|
13
|
+
// Keep expanding until no more placeholders remain or we hit the limit
|
|
14
|
+
while (/\{\{[^}]+\}\}/.test(result) && iteration < maxIterations) {
|
|
15
|
+
// Use a more complex regex that captures the full placeholder including spaces
|
|
16
|
+
result = result.replace(/(\{\{\s*)(\w+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
|
|
17
|
+
const val = variables[varName];
|
|
18
|
+
// If variable not found, leave placeholder as-is with original spacing
|
|
19
|
+
if (val === undefined) {
|
|
20
|
+
return `${prefix}${varName}${suffix}`;
|
|
21
|
+
}
|
|
22
|
+
// Return the value (which may contain more placeholders to expand)
|
|
23
|
+
return val;
|
|
24
|
+
});
|
|
25
|
+
iteration++;
|
|
26
|
+
// Prevent infinite loops by checking if we're stuck
|
|
27
|
+
if (iteration > 1 && result === content) {
|
|
28
|
+
console.warn(chalk.yellow(`Warning: Potential infinite loop detected in variable expansion, stopping after ${iteration} iterations`));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
12
33
|
}
|
|
13
34
|
/**
|
|
14
35
|
* Processes copy_files tasks from a template.
|
package/doc/configuration.md
CHANGED
|
@@ -28,6 +28,63 @@ Instead of manual definition, `pt learn` and `pt update` automatically scan for
|
|
|
28
28
|
|
|
29
29
|
These variables are then used during `copy_files` operations to replace `{{variable_name}}` placeholders in copied files.
|
|
30
30
|
|
|
31
|
+
### Nested Variable Expansion (v0.36.0+)
|
|
32
|
+
|
|
33
|
+
Starting with v0.36.0, `pt` supports **nested variable expansion** — variables can contain other variable placeholders that are resolved iteratively. This enables powerful configuration patterns like:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# In ~/.env or parent directory .env file:
|
|
37
|
+
prefix='rst_{{ project }}'
|
|
38
|
+
project=MyProject
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
During initialization, the system will:
|
|
42
|
+
|
|
43
|
+
1. Load `prefix='rst_{{ project }}'` from `.env`
|
|
44
|
+
2. Detect that `prefix` contains a `{{ project }}` placeholder
|
|
45
|
+
3. Resolve `{{ project }}` to `MyProject`
|
|
46
|
+
4. Set `prefix` to `rst_MyProject`
|
|
47
|
+
|
|
48
|
+
This is particularly useful for:
|
|
49
|
+
|
|
50
|
+
- **Project naming conventions**: `prefix='app_{{ env }}'` + `env=prod` → `app_prod`
|
|
51
|
+
- **Path templates**: `template_path='docs/{{ project }}'` + `project=wiki` → `docs/wiki`
|
|
52
|
+
- **Multi-level configurations**: Combine multiple `.env` files with nested references
|
|
53
|
+
|
|
54
|
+
**How it works:**
|
|
55
|
+
|
|
56
|
+
- Variables are expanded iteratively (up to 10 passes) to prevent infinite loops
|
|
57
|
+
- Circular references are detected and stopped gracefully
|
|
58
|
+
- Missing nested variables remain as `{{ variable }}` placeholders
|
|
59
|
+
- Whitespace is preserved: `{{ unknown }}` stays as `{{ unknown }}` (not `{{unknown}}`)
|
|
60
|
+
|
|
61
|
+
### Parent Directory `.env` File Scanning
|
|
62
|
+
|
|
63
|
+
`pt` automatically scans parent directories for `.env` files and uses their values as defaults during initialization. This enables:
|
|
64
|
+
|
|
65
|
+
- **Project-wide defaults**: Store common values in a parent `.env` file
|
|
66
|
+
- **Environment-specific configurations**: Use different `.env` files for dev/staging/prod
|
|
67
|
+
- **Team collaboration**: Share common variable values across team projects
|
|
68
|
+
|
|
69
|
+
**Example:**
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Project structure:
|
|
73
|
+
my-project/
|
|
74
|
+
├── .env # Contains: prefix='rst_'
|
|
75
|
+
├── sub-project/ # Initialize here
|
|
76
|
+
│ └── ...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
When you run `pt init my-template sub-project`, the `prefix` variable will be pre-filled with `rst_` from the parent `.env` file.
|
|
80
|
+
|
|
81
|
+
**Behavior:**
|
|
82
|
+
|
|
83
|
+
- Scans from the current directory up to 3 parent levels
|
|
84
|
+
- Uses values from `.env` as defaults (still prompts if not in `.env`)
|
|
85
|
+
- `--vars` CLI option overrides `.env` values
|
|
86
|
+
- `.env` files are not committed to version control (use `.gitignore`)
|
|
87
|
+
|
|
31
88
|
## Post-Config Tasks
|
|
32
89
|
|
|
33
90
|
Post-config tasks are optional commands that run after a project is initialized. They can be defined in a template or auto-detected from the source directory.
|
|
@@ -57,6 +114,7 @@ If the directory contains a `.pt-template.json` or `template.json` file with a `
|
|
|
57
114
|
3. Alternatively, initialize a temporary project from your learned template (`pt init`), refine it manually, and then use `pt update` from that directory to "re-learn" the refined state.
|
|
58
115
|
|
|
59
116
|
**Security Note:** All post-config commands are subject to security validation:
|
|
117
|
+
|
|
60
118
|
- Dangerous commands (e.g., `curl`, `python`, `chmod`) trigger warnings with 5-second cancellation
|
|
61
119
|
- Absolute blocks (e.g., `sudo`, `rm -rf`, `dd`) are never allowed
|
|
62
120
|
- Rate limiting prevents runaway execution (50 commands per run)
|
|
@@ -125,7 +183,7 @@ Each task supports:
|
|
|
125
183
|
|
|
126
184
|
## Default Post-Config
|
|
127
185
|
|
|
128
|
-
Default post-config tasks are defined at the top level of `~/.pt/config.yaml` under `default_post_config`. They serve as suggestions when creating or updating templates via `pt learn`.
|
|
186
|
+
Default post-config tasks are defined at the top level of `~/.pt/config.yaml` under `default_post_config`. They serve as suggestions when creating or updating templates via `pt learn`.
|
|
129
187
|
|
|
130
188
|
Unlike previous versions, default tasks are **not** automatically applied during `pt init`. Instead, you select which ones to include when learning a template, and those selections are baked into the template's `post_config` list. This eliminates the need to repeat boilerplate setup (e.g. `git init`) across templates while keeping each template fully self-contained.
|
|
131
189
|
|
|
@@ -137,21 +195,21 @@ default_post_config:
|
|
|
137
195
|
description: "Initialize git repository"
|
|
138
196
|
- command: "git add -A && git commit -m 'Initial commit'"
|
|
139
197
|
description: "Initial git commit"
|
|
140
|
-
checked: false
|
|
198
|
+
checked: false # default on, but user must manually check
|
|
141
199
|
- command: "git lfs install"
|
|
142
200
|
description: "Install git-lfs hooks"
|
|
143
|
-
type: "godot"
|
|
201
|
+
type: "godot" # only applies to godot projects
|
|
144
202
|
```
|
|
145
203
|
|
|
146
204
|
### Fields
|
|
147
205
|
|
|
148
206
|
Each default task supports the same fields as template post-config:
|
|
149
207
|
|
|
150
|
-
| Field | Description
|
|
151
|
-
| ------------- |
|
|
152
|
-
| `command` | Shell command to run
|
|
153
|
-
| `description` | Shown to user during interactive selection
|
|
154
|
-
| `checked` | Default checkbox state (`true` by default); set `false` to require manual opt-in
|
|
208
|
+
| Field | Description |
|
|
209
|
+
| ------------- | -------------------------------------------------------------------------------------------------- |
|
|
210
|
+
| `command` | Shell command to run |
|
|
211
|
+
| `description` | Shown to user during interactive selection |
|
|
212
|
+
| `checked` | Default checkbox state (`true` by default); set `false` to require manual opt-in |
|
|
155
213
|
| `type` | Filter by project type (e.g. `"javascript"`); if set, task only applies when template type matches |
|
|
156
214
|
|
|
157
215
|
### Behavior
|
|
@@ -163,6 +221,7 @@ Each default task supports the same fields as template post-config:
|
|
|
163
221
|
|
|
164
222
|
You can view current default tasks using `pt config` or `pt default-post-config`.
|
|
165
223
|
To update default tasks programmatically or via CLI, use the `pt default-post-config` command:
|
|
224
|
+
|
|
166
225
|
- `pt default-post-config`: List current default post-config tasks.
|
|
167
226
|
- `pt default-post-config --set --json '...'`: Replace the default post-config tasks list via a JSON string or file.
|
|
168
227
|
|
|
@@ -247,6 +306,7 @@ Each entry supports:
|
|
|
247
306
|
A plausible scenario for customizing a new project's `package.json` and `README.md`:
|
|
248
307
|
|
|
249
308
|
**1. Define in `config.yaml`**:
|
|
309
|
+
|
|
250
310
|
```yaml
|
|
251
311
|
templates:
|
|
252
312
|
node_web_app:
|
|
@@ -265,6 +325,7 @@ templates:
|
|
|
265
325
|
```
|
|
266
326
|
|
|
267
327
|
**2. Template source (`templates/package.json.tmpl`)**:
|
|
328
|
+
|
|
268
329
|
```json
|
|
269
330
|
{
|
|
270
331
|
"name": "{{project_name}}",
|
|
@@ -275,6 +336,7 @@ templates:
|
|
|
275
336
|
|
|
276
337
|
**3. Resulting project file**:
|
|
277
338
|
If the user enters `my-service` and `Jane Doe`, the file `package.json` will be created with:
|
|
339
|
+
|
|
278
340
|
```json
|
|
279
341
|
{
|
|
280
342
|
"name": "my-service",
|