@garyr/pt-cli 0.33.0 → 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/defaultPostConfigCommand.js +2 -1
- package/dist/commands/initCommand.js +112 -15
- package/dist/commands/learnCommand.js +70 -1
- package/dist/commands/securityResponseCommand.js +12 -0
- package/dist/index.js +31 -2
- package/dist/remote.js +30 -14
- package/dist/safety.js +57 -27
- package/dist/substitute.js +42 -7
- package/doc/configuration.md +52 -0
- package/doc/usage.md +66 -0
- package/doc/variable_substitution_example.md +64 -0
- package/package.json +2 -2
- package/src/commands/defaultPostConfigCommand.ts +2 -1
- package/src/commands/initCommand.ts +127 -14
- package/src/commands/learnCommand.ts +72 -1
- package/src/commands/securityResponseCommand.ts +18 -0
- package/src/index.ts +28 -2
- package/src/remote.ts +31 -16
- package/src/safety.ts +61 -29
- package/src/substitute.ts +52 -7
- 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/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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@garyr/pt-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Project Template CLI - Learn structures and initialize projects",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -43,4 +43,4 @@
|
|
|
43
43
|
"tsx": "^4.21.0",
|
|
44
44
|
"typescript": "^5.6.0"
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
}
|
|
@@ -27,6 +27,7 @@ export function defaultPostConfigCommand(options: DefaultPostConfigOptions = {})
|
|
|
27
27
|
|
|
28
28
|
console.error('You must provide --json <data> to set the default post-config array.');
|
|
29
29
|
} else {
|
|
30
|
-
|
|
30
|
+
const tasks = config.default_post_config || [];
|
|
31
|
+
console.log(JSON.stringify(tasks, null, 2));
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -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());
|
|
@@ -172,26 +249,29 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
172
249
|
if (fs.existsSync(srcPath)) {
|
|
173
250
|
if (options.dryRun) {
|
|
174
251
|
console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
|
|
175
|
-
|
|
176
|
-
if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
|
|
177
|
-
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
178
|
-
}
|
|
252
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
179
253
|
continue;
|
|
180
254
|
}
|
|
181
255
|
|
|
182
|
-
|
|
256
|
+
let fileContent = fs.readFileSync(srcPath, 'utf-8');
|
|
257
|
+
|
|
258
|
+
// Substitute variables in post_copy files if template has variables
|
|
259
|
+
if (template.variables && template.variables.length > 0) {
|
|
260
|
+
const { substituteVariables } = await import('../substitute.js');
|
|
261
|
+
fileContent = substituteVariables(fileContent, variables);
|
|
262
|
+
}
|
|
263
|
+
|
|
183
264
|
const destDir = path.dirname(destPath);
|
|
184
265
|
fs.mkdirSync(destDir, { recursive: true });
|
|
185
266
|
fs.writeFileSync(destPath, fileContent);
|
|
186
267
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
268
|
+
// post_copy files are executables by definition — always chmod
|
|
269
|
+
try {
|
|
270
|
+
// Check if source had execute permissions, otherwise default to 0o755
|
|
271
|
+
const srcStat = fs.statSync(srcPath);
|
|
272
|
+
fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
// chmod not available (Windows)
|
|
195
275
|
}
|
|
196
276
|
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
197
277
|
} else {
|
|
@@ -210,7 +290,40 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
210
290
|
// Use template post_config tasks
|
|
211
291
|
const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName!) || [];
|
|
212
292
|
|
|
213
|
-
if (allTasks.length > 0) {
|
|
293
|
+
if (allTasks.length > 0 && !options.skipPostConfig) {
|
|
294
|
+
// SECURITY CHECK: Validate template safety before running post_config tasks
|
|
295
|
+
const { validateTemplateSecurity } = await import('../safety.js');
|
|
296
|
+
const { valid, errors, warnings } = validateTemplateSecurity(template);
|
|
297
|
+
|
|
298
|
+
if (!valid) {
|
|
299
|
+
console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
|
|
300
|
+
for (const err of errors) {
|
|
301
|
+
console.error(chalk.red(` - ${err}`));
|
|
302
|
+
}
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (warnings.length > 0) {
|
|
307
|
+
console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
|
|
308
|
+
for (const warn of warnings) {
|
|
309
|
+
console.warn(chalk.yellow(` - ${warn}`));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!options.yes) {
|
|
313
|
+
const { proceed } = await inquirer.prompt({
|
|
314
|
+
type: 'confirm',
|
|
315
|
+
name: 'proceed',
|
|
316
|
+
message: chalk.red('Are you sure you want to run these post-config tasks?'),
|
|
317
|
+
default: false
|
|
318
|
+
});
|
|
319
|
+
if (!proceed) {
|
|
320
|
+
console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
214
327
|
// Determine which tasks to include
|
|
215
328
|
let selectedTaskNames: string[] = [];
|
|
216
329
|
|
|
@@ -11,6 +11,7 @@ export interface LearnOptions {
|
|
|
11
11
|
name?: string;
|
|
12
12
|
desc?: string;
|
|
13
13
|
json?: boolean;
|
|
14
|
+
allowUntrusted?: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
|
|
@@ -20,7 +21,15 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
20
21
|
// Phase 1: Remote Check
|
|
21
22
|
if (sourcePath.startsWith('http')) {
|
|
22
23
|
console.log(chalk.cyan(`Downloading remote template from: ${sourcePath}...`));
|
|
23
|
-
|
|
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
|
+
}
|
|
24
33
|
} else {
|
|
25
34
|
resolvedPath = path.resolve(sourcePath);
|
|
26
35
|
}
|
|
@@ -77,6 +86,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
77
86
|
process.exit(1);
|
|
78
87
|
}
|
|
79
88
|
} else {
|
|
89
|
+
// Track whether name came from .info.md or JSON (not user-provided)
|
|
90
|
+
const nameFromSource = !options.name && (fileTemplateConfig.name || infoName);
|
|
91
|
+
|
|
80
92
|
if (options.name) {
|
|
81
93
|
targetName = options.name;
|
|
82
94
|
} else if (fileTemplateConfig.name) {
|
|
@@ -98,6 +110,34 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
98
110
|
targetName = newName;
|
|
99
111
|
}
|
|
100
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
|
+
}
|
|
101
141
|
}
|
|
102
142
|
|
|
103
143
|
let description = '';
|
|
@@ -128,6 +168,9 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
128
168
|
}
|
|
129
169
|
}
|
|
130
170
|
} else {
|
|
171
|
+
// Track whether description came from .info.md or JSON (not user-provided)
|
|
172
|
+
const descFromSource = !options.desc && (fileTemplateConfig.description || infoDesc);
|
|
173
|
+
|
|
131
174
|
if (fileTemplateConfig.description) {
|
|
132
175
|
description = fileTemplateConfig.description;
|
|
133
176
|
if (!options.json) console.log(chalk.cyan(`Auto-detected template description from JSON: ${description}`));
|
|
@@ -145,6 +188,34 @@ export async function learn(sourcePath: string, updateTemplate: string | null =
|
|
|
145
188
|
});
|
|
146
189
|
description = newDesc;
|
|
147
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
|
+
}
|
|
148
219
|
}
|
|
149
220
|
|
|
150
221
|
const cliIgnore = options.ignore ? options.ignore.split(',').map((s: string) => s.trim()).filter(Boolean) : [];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// pt-cli/src/commands/securityResponseCommand.ts
|
|
2
|
+
// CLI command to handle security responses from GUI
|
|
3
|
+
|
|
4
|
+
import { handleSecurityResponse } from '../safety.js';
|
|
5
|
+
|
|
6
|
+
export interface SecurityResponseOptions {
|
|
7
|
+
response: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function securityResponseCommand(response: string, options: SecurityResponseOptions = { response: '' }): Promise<void> {
|
|
11
|
+
const result = await handleSecurityResponse(response);
|
|
12
|
+
|
|
13
|
+
if (result) {
|
|
14
|
+
console.log('SECURITY_RESPONSE:ALLOWED');
|
|
15
|
+
} else {
|
|
16
|
+
console.log('SECURITY_RESPONSE:DENIED');
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { variablesCommand } from './commands/variablesCommand.js';
|
|
|
14
14
|
import { addCommand } from './commands/addCommand.js';
|
|
15
15
|
import { removeCommand } from './commands/removeCommand.js';
|
|
16
16
|
import { defaultPostConfigCommand } from './commands/defaultPostConfigCommand.js';
|
|
17
|
+
import { securityResponseCommand } from './commands/securityResponseCommand.js';
|
|
17
18
|
|
|
18
19
|
import pkg from '../package.json' with { type: 'json' };
|
|
19
20
|
|
|
@@ -32,8 +33,21 @@ program
|
|
|
32
33
|
.option('--name <name>', 'Template name (skip prompt)')
|
|
33
34
|
.option('--desc <description>', 'Template description (skip prompt)')
|
|
34
35
|
.option('--json', 'Output template structure as JSON for sharing instead of saving')
|
|
36
|
+
.option('--allow-untrusted', 'Bypass the trusted-source check for remote URLs (set by GUI after user confirmation)')
|
|
35
37
|
.action(async (pathArg: string | undefined, options) => {
|
|
36
|
-
|
|
38
|
+
try {
|
|
39
|
+
await learn(pathArg || '.', null, options);
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
if (options.json) {
|
|
42
|
+
console.log(JSON.stringify({
|
|
43
|
+
type: 'error',
|
|
44
|
+
message: err.message || String(err)
|
|
45
|
+
}));
|
|
46
|
+
} else {
|
|
47
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
48
|
+
}
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
37
51
|
});
|
|
38
52
|
|
|
39
53
|
program
|
|
@@ -43,7 +57,12 @@ program
|
|
|
43
57
|
.option('-y, --yes', 'Automatically confirm prompts')
|
|
44
58
|
.option('--desc <description>', 'Template description (skip prompt)')
|
|
45
59
|
.action(async (templateName: string, sourcePath: string | undefined, options) => {
|
|
46
|
-
|
|
60
|
+
try {
|
|
61
|
+
await learn(sourcePath || '.', templateName, options);
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
47
66
|
});
|
|
48
67
|
|
|
49
68
|
program
|
|
@@ -98,4 +117,11 @@ program
|
|
|
98
117
|
.option('-y, --yes', 'Automatically confirm removal')
|
|
99
118
|
.action(removeCommand);
|
|
100
119
|
|
|
120
|
+
program
|
|
121
|
+
.command('security-response <response>')
|
|
122
|
+
.description('Handle security response from GUI')
|
|
123
|
+
.action(async (response: string) => {
|
|
124
|
+
await securityResponseCommand(response);
|
|
125
|
+
});
|
|
126
|
+
|
|
101
127
|
program.parse(process.argv);
|
package/src/remote.ts
CHANGED
|
@@ -9,7 +9,7 @@ import chalk from 'chalk';
|
|
|
9
9
|
import { isTrustedSource, logSecurityEvent, getSecurityPolicy } from './safety.js';
|
|
10
10
|
import { loadConfig } from './config.js';
|
|
11
11
|
|
|
12
|
-
export async function downloadAndExtract(url: string): Promise<string> {
|
|
12
|
+
export async function downloadAndExtract(url: string, isJsonMode: boolean = false, allowUntrusted: boolean = false): Promise<string> {
|
|
13
13
|
// Load security policy
|
|
14
14
|
const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
|
|
15
15
|
const config = loadConfig();
|
|
@@ -17,21 +17,36 @@ export async function downloadAndExtract(url: string): Promise<string> {
|
|
|
17
17
|
trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
// SECURITY CHECK: Verify source is trusted
|
|
21
|
-
if (!isTrustedSource(url, securityPolicy.trustedSources)) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
20
|
+
// SECURITY CHECK: Verify source is trusted (skipped when --allow-untrusted is passed)
|
|
21
|
+
if (!allowUntrusted && !isTrustedSource(url, securityPolicy.trustedSources)) {
|
|
22
|
+
if (isJsonMode) {
|
|
23
|
+
// In JSON mode, output warning as JSON for GUI consumption.
|
|
24
|
+
// The GUI will show a confirmation dialog and, if the user says YES,
|
|
25
|
+
// re-run `pt learn <url> --json --yes --allow-untrusted`.
|
|
26
|
+
console.log(JSON.stringify({
|
|
27
|
+
type: 'security_warning',
|
|
28
|
+
url: url,
|
|
29
|
+
message: `Template from untrusted source: ${url}`,
|
|
30
|
+
warning: 'Only use templates from trusted sources.',
|
|
31
|
+
prompt: 'Continue anyway?',
|
|
32
|
+
default: false
|
|
33
|
+
}));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
} else {
|
|
36
|
+
console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
|
|
37
|
+
console.log(chalk.yellow(' Only use templates from trusted sources'));
|
|
38
|
+
|
|
39
|
+
const inquirer = (await import('inquirer')).default;
|
|
40
|
+
const response = await inquirer.prompt({
|
|
41
|
+
type: 'confirm',
|
|
42
|
+
name: 'proceed',
|
|
43
|
+
message: chalk.red('Continue anyway?'),
|
|
44
|
+
default: false
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.proceed) {
|
|
48
|
+
throw new Error('Download cancelled by user due to untrusted source');
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
}
|
|
37
52
|
|