@garyr/pt-cli 0.36.4 → 0.38.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/README.md +3 -1
- package/dist/commands/initCommand.js +65 -0
- package/dist/commands/learnCommand.js +70 -1
- package/dist/substitute.js +25 -4
- package/doc/configuration.md +52 -0
- package/doc/usage.md +66 -0
- package/doc/variable_substitution_example.md +64 -0
- package/package.json +1 -1
- package/src/commands/initCommand.ts +77 -0
- package/src/commands/learnCommand.ts +71 -1
- 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
|
|
|
@@ -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];
|
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,58 @@ 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
|
+
1. Load `prefix='rst_{{ project }}'` from `.env`
|
|
43
|
+
2. Detect that `prefix` contains a `{{ project }}` placeholder
|
|
44
|
+
3. Resolve `{{ project }}` to `MyProject`
|
|
45
|
+
4. Set `prefix` to `rst_MyProject`
|
|
46
|
+
|
|
47
|
+
This is particularly useful for:
|
|
48
|
+
- **Project naming conventions**: `prefix='app_{{ env }}'` + `env=prod` → `app_prod`
|
|
49
|
+
- **Path templates**: `template_path='docs/{{ project }}'` + `project=wiki` → `docs/wiki`
|
|
50
|
+
- **Multi-level configurations**: Combine multiple `.env` files with nested references
|
|
51
|
+
|
|
52
|
+
**How it works:**
|
|
53
|
+
- Variables are expanded iteratively (up to 10 passes) to prevent infinite loops
|
|
54
|
+
- Circular references are detected and stopped gracefully
|
|
55
|
+
- Missing nested variables remain as `{{ variable }}` placeholders
|
|
56
|
+
- Whitespace is preserved: `{{ unknown }}` stays as `{{ unknown }}` (not `{{unknown}}`)
|
|
57
|
+
|
|
58
|
+
### Parent Directory `.env` File Scanning
|
|
59
|
+
|
|
60
|
+
`pt` automatically scans parent directories for `.env` files and uses their values as defaults during initialization. This enables:
|
|
61
|
+
|
|
62
|
+
- **Project-wide defaults**: Store common values in a parent `.env` file
|
|
63
|
+
- **Environment-specific configurations**: Use different `.env` files for dev/staging/prod
|
|
64
|
+
- **Team collaboration**: Share common variable values across team projects
|
|
65
|
+
|
|
66
|
+
**Example:**
|
|
67
|
+
```bash
|
|
68
|
+
# Project structure:
|
|
69
|
+
my-project/
|
|
70
|
+
├── .env # Contains: prefix='rst_'
|
|
71
|
+
├── sub-project/ # Initialize here
|
|
72
|
+
│ └── ...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When you run `pt init my-template sub-project`, the `prefix` variable will be pre-filled with `rst_` from the parent `.env` file.
|
|
76
|
+
|
|
77
|
+
**Behavior:**
|
|
78
|
+
- Scans from the current directory up to 3 parent levels
|
|
79
|
+
- Uses values from `.env` as defaults (still prompts if not in `.env`)
|
|
80
|
+
- `--vars` CLI option overrides `.env` values
|
|
81
|
+
- `.env` files are not committed to version control (use `.gitignore`)
|
|
82
|
+
|
|
31
83
|
## Post-Config Tasks
|
|
32
84
|
|
|
33
85
|
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.
|
package/doc/usage.md
CHANGED
|
@@ -46,6 +46,72 @@ 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
|
+
- **Automatic Scanning:** Scans up to 3 parent directories for `.env` files
|
|
64
|
+
- **Variable Pre-filling:** Values from `.env` are used as defaults in prompts
|
|
65
|
+
- **Nested Variables:** Supports nested placeholders like `prefix='app_{{ env }}'`
|
|
66
|
+
- **Override Support:** Use `--vars` to override `.env` values: `--vars project=OverriddenProject`
|
|
67
|
+
|
|
68
|
+
**Example with Nested Variables:**
|
|
69
|
+
```bash
|
|
70
|
+
# .env file
|
|
71
|
+
prefix='app_{{ env }}'
|
|
72
|
+
env=prod
|
|
73
|
+
project=MyApp
|
|
74
|
+
|
|
75
|
+
# Template README.md.tmpl
|
|
76
|
+
# Content: {{prefix}}_{{project}}
|
|
77
|
+
|
|
78
|
+
# Result: app_prod_MyApp
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Security Note:** `.env` files are not committed to version control. Use `.gitignore` to exclude them:
|
|
82
|
+
```bash
|
|
83
|
+
# .gitignore
|
|
84
|
+
.env
|
|
85
|
+
*.env
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Nested Variable Expansion (v0.36.0+)
|
|
89
|
+
|
|
90
|
+
The `pt` CLI now supports **nested variable expansion** — variables can contain other variable placeholders that are resolved iteratively. This enables powerful configuration patterns:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# In .env file:
|
|
94
|
+
prefix='rst_{{ project }}'
|
|
95
|
+
project=MyProject
|
|
96
|
+
|
|
97
|
+
# During initialization, the system will:
|
|
98
|
+
# 1. Load prefix='rst_{{ project }}' from .env
|
|
99
|
+
# 2. Detect that prefix contains a {{ project }} placeholder
|
|
100
|
+
# 3. Resolve {{ project }} to MyProject
|
|
101
|
+
# 4. Set prefix to rst_MyProject
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Use Cases:**
|
|
105
|
+
- **Project naming conventions:** `prefix='app_{{ env }}'` + `env=prod` → `app_prod`
|
|
106
|
+
- **Path templates:** `template_path='docs/{{ project }}'` + `project=wiki` → `docs/wiki`
|
|
107
|
+
- **Multi-level configurations:** Combine multiple `.env` files with nested references
|
|
108
|
+
|
|
109
|
+
**How it works:**
|
|
110
|
+
- Variables are expanded iteratively (up to 10 passes) to prevent infinite loops
|
|
111
|
+
- Circular references are detected and stopped gracefully
|
|
112
|
+
- Missing nested variables remain as `{{ variable }}` placeholders
|
|
113
|
+
- Whitespace is preserved: `{{ unknown }}` stays as `{{ unknown }}` (not `{{unknown}}`)
|
|
114
|
+
|
|
49
115
|
## Initialize a project
|
|
50
116
|
|
|
51
117
|
```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
|
@@ -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) : [];
|