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