@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.
@@ -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.33.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
- console.log('Current default post-config tasks:', config.default_post_config || []);
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
- const ext = path.extname(file.src);
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
- const fileContent = fs.readFileSync(srcPath, 'utf-8');
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
- // Auto-chmod for executables
188
- const ext = path.extname(file.src);
189
- if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
190
- try {
191
- fs.chmodSync(destPath, 0o755);
192
- } catch (e) {
193
- // chmod not available (Windows)
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
- resolvedPath = await downloadAndExtract(sourcePath);
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
- await learn(pathArg || '.', null, options);
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
- await learn(sourcePath || '.', templateName, options);
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
- console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
23
- console.log(chalk.yellow(' Only use templates from trusted sources'));
24
-
25
- const inquirer = (await import('inquirer')).default;
26
- const response = await inquirer.prompt({
27
- type: 'confirm',
28
- name: 'proceed',
29
- message: chalk.red('Continue anyway?'),
30
- default: false
31
- });
32
-
33
- if (!response.proceed) {
34
- throw new Error('Download cancelled by user due to untrusted source');
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