@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/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
|
|
|
@@ -21,6 +21,7 @@ export function defaultPostConfigCommand(options = {}) {
|
|
|
21
21
|
console.error('You must provide --json <data> to set the default post-config array.');
|
|
22
22
|
}
|
|
23
23
|
else {
|
|
24
|
-
|
|
24
|
+
const tasks = config.default_post_config || [];
|
|
25
|
+
console.log(JSON.stringify(tasks, null, 2));
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -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());
|
|
@@ -153,25 +218,26 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
153
218
|
if (fs.existsSync(srcPath)) {
|
|
154
219
|
if (options.dryRun) {
|
|
155
220
|
console.log(chalk.gray(` [DRY RUN] Would copy ${file.src} → ${file.dest || file.src}`));
|
|
156
|
-
|
|
157
|
-
if (['.sh', '.py', '.bash', '.bat'].includes(ext)) {
|
|
158
|
-
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
159
|
-
}
|
|
221
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
160
222
|
continue;
|
|
161
223
|
}
|
|
162
|
-
|
|
224
|
+
let fileContent = fs.readFileSync(srcPath, 'utf-8');
|
|
225
|
+
// Substitute variables in post_copy files if template has variables
|
|
226
|
+
if (template.variables && template.variables.length > 0) {
|
|
227
|
+
const { substituteVariables } = await import('../substitute.js');
|
|
228
|
+
fileContent = substituteVariables(fileContent, variables);
|
|
229
|
+
}
|
|
163
230
|
const destDir = path.dirname(destPath);
|
|
164
231
|
fs.mkdirSync(destDir, { recursive: true });
|
|
165
232
|
fs.writeFileSync(destPath, fileContent);
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
233
|
+
// post_copy files are executables by definition — always chmod
|
|
234
|
+
try {
|
|
235
|
+
// Check if source had execute permissions, otherwise default to 0o755
|
|
236
|
+
const srcStat = fs.statSync(srcPath);
|
|
237
|
+
fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
// chmod not available (Windows)
|
|
175
241
|
}
|
|
176
242
|
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
177
243
|
}
|
|
@@ -190,7 +256,38 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
190
256
|
}
|
|
191
257
|
// Use template post_config tasks
|
|
192
258
|
const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName) || [];
|
|
193
|
-
if (allTasks.length > 0) {
|
|
259
|
+
if (allTasks.length > 0 && !options.skipPostConfig) {
|
|
260
|
+
// SECURITY CHECK: Validate template safety before running post_config tasks
|
|
261
|
+
const { validateTemplateSecurity } = await import('../safety.js');
|
|
262
|
+
const { valid, errors, warnings } = validateTemplateSecurity(template);
|
|
263
|
+
if (!valid) {
|
|
264
|
+
console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
|
|
265
|
+
for (const err of errors) {
|
|
266
|
+
console.error(chalk.red(` - ${err}`));
|
|
267
|
+
}
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
if (warnings.length > 0) {
|
|
271
|
+
console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
|
|
272
|
+
for (const warn of warnings) {
|
|
273
|
+
console.warn(chalk.yellow(` - ${warn}`));
|
|
274
|
+
}
|
|
275
|
+
if (!options.yes) {
|
|
276
|
+
const { proceed } = await inquirer.prompt({
|
|
277
|
+
type: 'confirm',
|
|
278
|
+
name: 'proceed',
|
|
279
|
+
message: chalk.red('Are you sure you want to run these post-config tasks?'),
|
|
280
|
+
default: false
|
|
281
|
+
});
|
|
282
|
+
if (!proceed) {
|
|
283
|
+
console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
194
291
|
// Determine which tasks to include
|
|
195
292
|
let selectedTaskNames = [];
|
|
196
293
|
if (options.skipPostConfig) {
|
|
@@ -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];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// pt-cli/src/commands/securityResponseCommand.ts
|
|
2
|
+
// CLI command to handle security responses from GUI
|
|
3
|
+
import { handleSecurityResponse } from '../safety.js';
|
|
4
|
+
export async function securityResponseCommand(response, options = { response: '' }) {
|
|
5
|
+
const result = await handleSecurityResponse(response);
|
|
6
|
+
if (result) {
|
|
7
|
+
console.log('SECURITY_RESPONSE:ALLOWED');
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
console.log('SECURITY_RESPONSE:DENIED');
|
|
11
|
+
}
|
|
12
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
3
4
|
// Command imports
|
|
4
5
|
import { learn } from './commands/learnCommand.js';
|
|
5
6
|
import { init } from './commands/initCommand.js';
|
|
@@ -9,6 +10,7 @@ import { variablesCommand } from './commands/variablesCommand.js';
|
|
|
9
10
|
import { addCommand } from './commands/addCommand.js';
|
|
10
11
|
import { removeCommand } from './commands/removeCommand.js';
|
|
11
12
|
import { defaultPostConfigCommand } from './commands/defaultPostConfigCommand.js';
|
|
13
|
+
import { securityResponseCommand } from './commands/securityResponseCommand.js';
|
|
12
14
|
import pkg from '../package.json' with { type: 'json' };
|
|
13
15
|
const program = new Command();
|
|
14
16
|
program
|
|
@@ -23,8 +25,23 @@ program
|
|
|
23
25
|
.option('--name <name>', 'Template name (skip prompt)')
|
|
24
26
|
.option('--desc <description>', 'Template description (skip prompt)')
|
|
25
27
|
.option('--json', 'Output template structure as JSON for sharing instead of saving')
|
|
28
|
+
.option('--allow-untrusted', 'Bypass the trusted-source check for remote URLs (set by GUI after user confirmation)')
|
|
26
29
|
.action(async (pathArg, options) => {
|
|
27
|
-
|
|
30
|
+
try {
|
|
31
|
+
await learn(pathArg || '.', null, options);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (options.json) {
|
|
35
|
+
console.log(JSON.stringify({
|
|
36
|
+
type: 'error',
|
|
37
|
+
message: err.message || String(err)
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
42
|
+
}
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
28
45
|
});
|
|
29
46
|
program
|
|
30
47
|
.command('update <templateName> [sourcePath]')
|
|
@@ -33,7 +50,13 @@ program
|
|
|
33
50
|
.option('-y, --yes', 'Automatically confirm prompts')
|
|
34
51
|
.option('--desc <description>', 'Template description (skip prompt)')
|
|
35
52
|
.action(async (templateName, sourcePath, options) => {
|
|
36
|
-
|
|
53
|
+
try {
|
|
54
|
+
await learn(sourcePath || '.', templateName, options);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(chalk.red(`Error: ${err.message || err}`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
37
60
|
});
|
|
38
61
|
program
|
|
39
62
|
.command('init [templateName] [destPath]')
|
|
@@ -80,4 +103,10 @@ program
|
|
|
80
103
|
.description('Remove a learned template from the config')
|
|
81
104
|
.option('-y, --yes', 'Automatically confirm removal')
|
|
82
105
|
.action(removeCommand);
|
|
106
|
+
program
|
|
107
|
+
.command('security-response <response>')
|
|
108
|
+
.description('Handle security response from GUI')
|
|
109
|
+
.action(async (response) => {
|
|
110
|
+
await securityResponseCommand(response);
|
|
111
|
+
});
|
|
83
112
|
program.parse(process.argv);
|
package/dist/remote.js
CHANGED
|
@@ -8,26 +8,42 @@ import { extract } from 'tar'; // You'll need: npm install tar
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { isTrustedSource, logSecurityEvent } from './safety.js';
|
|
10
10
|
import { loadConfig } from './config.js';
|
|
11
|
-
export async function downloadAndExtract(url) {
|
|
11
|
+
export async function downloadAndExtract(url, isJsonMode = false, allowUntrusted = false) {
|
|
12
12
|
// Load security policy
|
|
13
13
|
const configPath = path.join(process.env.HOME || os.homedir(), '.pt', 'config.yaml');
|
|
14
14
|
const config = loadConfig();
|
|
15
15
|
const securityPolicy = config.security || {
|
|
16
16
|
trustedSources: ['github.com/garyritchie', 'gitea.lyonritchie.com/garyritchie', 'github.com/lyonritchie'],
|
|
17
17
|
};
|
|
18
|
-
// SECURITY CHECK: Verify source is trusted
|
|
19
|
-
if (!isTrustedSource(url, securityPolicy.trustedSources)) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
// SECURITY CHECK: Verify source is trusted (skipped when --allow-untrusted is passed)
|
|
19
|
+
if (!allowUntrusted && !isTrustedSource(url, securityPolicy.trustedSources)) {
|
|
20
|
+
if (isJsonMode) {
|
|
21
|
+
// In JSON mode, output warning as JSON for GUI consumption.
|
|
22
|
+
// The GUI will show a confirmation dialog and, if the user says YES,
|
|
23
|
+
// re-run `pt learn <url> --json --yes --allow-untrusted`.
|
|
24
|
+
console.log(JSON.stringify({
|
|
25
|
+
type: 'security_warning',
|
|
26
|
+
url: url,
|
|
27
|
+
message: `Template from untrusted source: ${url}`,
|
|
28
|
+
warning: 'Only use templates from trusted sources.',
|
|
29
|
+
prompt: 'Continue anyway?',
|
|
30
|
+
default: false
|
|
31
|
+
}));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log(chalk.yellow(`⚠️ Warning: Template from untrusted source: ${url}`));
|
|
36
|
+
console.log(chalk.yellow(' Only use templates from trusted sources'));
|
|
37
|
+
const inquirer = (await import('inquirer')).default;
|
|
38
|
+
const response = await inquirer.prompt({
|
|
39
|
+
type: 'confirm',
|
|
40
|
+
name: 'proceed',
|
|
41
|
+
message: chalk.red('Continue anyway?'),
|
|
42
|
+
default: false
|
|
43
|
+
});
|
|
44
|
+
if (!response.proceed) {
|
|
45
|
+
throw new Error('Download cancelled by user due to untrusted source');
|
|
46
|
+
}
|
|
31
47
|
}
|
|
32
48
|
}
|
|
33
49
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-template-'));
|
package/dist/safety.js
CHANGED
|
@@ -12,8 +12,8 @@ const BLOCKED_COMMANDS = [
|
|
|
12
12
|
'sudo', 'su', 'su -', 'su root',
|
|
13
13
|
// Disk operations that could destroy data
|
|
14
14
|
'dd', 'mkfs', 'fdisk', 'mount', 'umount',
|
|
15
|
-
// Shell injection patterns
|
|
16
|
-
|
|
15
|
+
// Shell injection patterns - removed from blocklist to allow command chaining.
|
|
16
|
+
// These are validated as warnings instead.
|
|
17
17
|
// Dangerous chmod
|
|
18
18
|
'chmod 777', 'chmod -R 777', 'chmod 666',
|
|
19
19
|
// System commands that could kill processes
|
|
@@ -26,8 +26,6 @@ const BLOCKED_COMMANDS = [
|
|
|
26
26
|
// === DANGEROUS PATTERNS ===
|
|
27
27
|
// Commands that should trigger a warning but are NOT blocked
|
|
28
28
|
const DANGEROUS_PATTERNS = [
|
|
29
|
-
// Destructive file operations
|
|
30
|
-
'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
|
|
31
29
|
// Remote downloads + execution
|
|
32
30
|
'curl', 'wget', 'wget -O', 'curl |', 'wget |',
|
|
33
31
|
// Script execution
|
|
@@ -69,6 +67,31 @@ export function isBlockedCommand(command) {
|
|
|
69
67
|
* Check if a command should trigger a warning (but is allowed)
|
|
70
68
|
*/
|
|
71
69
|
export function isDangerousCommand(command) {
|
|
70
|
+
// Check for destructive file operations targeting absolute paths
|
|
71
|
+
// Regex matches 'rm', 'rmdir', or Windows 'del' followed by optional flags and then an absolute path.
|
|
72
|
+
// Absolute path matches:
|
|
73
|
+
// - Unix: starting with '/' (e.g. /tmp, /usr)
|
|
74
|
+
// - Windows: starting with drive letter (e.g. C:\) or UNC path (\\) or drive-relative '\'
|
|
75
|
+
const parts = command.split(/\s+/);
|
|
76
|
+
const rmIndex = parts.findIndex(p => p === 'rm' || p === 'rmdir' || p === 'del');
|
|
77
|
+
if (rmIndex !== -1) {
|
|
78
|
+
// Check subsequent arguments
|
|
79
|
+
for (let i = rmIndex + 1; i < parts.length; i++) {
|
|
80
|
+
const arg = parts[i];
|
|
81
|
+
if (!arg)
|
|
82
|
+
continue;
|
|
83
|
+
// Skip flags (starting with - or /flag on Windows)
|
|
84
|
+
if (arg.startsWith('-') || (process.platform === 'win32' && arg.startsWith('/'))) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Check absolute path patterns
|
|
88
|
+
const isAbsolute = arg.startsWith('/') ||
|
|
89
|
+
(process.platform === 'win32' && (/^[a-zA-Z]:\\/.test(arg) || arg.startsWith('\\\\') || arg.startsWith('\\')));
|
|
90
|
+
if (isAbsolute) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
72
95
|
for (const pattern of DANGEROUS_PATTERNS) {
|
|
73
96
|
if (command.includes(pattern)) {
|
|
74
97
|
return true;
|
|
@@ -135,31 +158,31 @@ export function isTrustedSource(url, trustedSources = DEFAULT_SECURITY_POLICY.tr
|
|
|
135
158
|
return trustedSources.some(source => url.includes(source));
|
|
136
159
|
}
|
|
137
160
|
/**
|
|
138
|
-
*
|
|
161
|
+
* Rate limiting: track the last execution timestamp per command hash
|
|
139
162
|
*/
|
|
140
|
-
const
|
|
163
|
+
const lastExecutionTimes = new Map();
|
|
141
164
|
const RATE_LIMIT_MS = 1000; // 1 second between identical commands
|
|
142
165
|
export function canExecute(command, maxCommandsPerRun = 50) {
|
|
143
|
-
// Check total
|
|
144
|
-
const totalExecuted =
|
|
166
|
+
// Check total commands executed this run
|
|
167
|
+
const totalExecuted = lastExecutionTimes.size;
|
|
145
168
|
if (totalExecuted >= maxCommandsPerRun) {
|
|
146
169
|
return false;
|
|
147
170
|
}
|
|
148
171
|
// Check rate limit for this specific command
|
|
149
172
|
const hash = crypto.createHash('md5').update(command).digest('hex');
|
|
150
|
-
const
|
|
151
|
-
if (Date.now() -
|
|
173
|
+
const lastTime = lastExecutionTimes.get(hash) || 0;
|
|
174
|
+
if (Date.now() - lastTime < RATE_LIMIT_MS) {
|
|
152
175
|
return false;
|
|
153
176
|
}
|
|
154
|
-
//
|
|
155
|
-
|
|
177
|
+
// Record execution timestamp
|
|
178
|
+
lastExecutionTimes.set(hash, Date.now());
|
|
156
179
|
return true;
|
|
157
180
|
}
|
|
158
181
|
/**
|
|
159
|
-
* Reset execution
|
|
182
|
+
* Reset execution timestamps (for testing or between runs)
|
|
160
183
|
*/
|
|
161
184
|
export function resetExecutionCounts() {
|
|
162
|
-
|
|
185
|
+
lastExecutionTimes.clear();
|
|
163
186
|
}
|
|
164
187
|
/**
|
|
165
188
|
* Validate a template's security before execution
|
|
@@ -216,35 +239,42 @@ export function getSecurityPolicy(configPath) {
|
|
|
216
239
|
return DEFAULT_SECURITY_POLICY;
|
|
217
240
|
}
|
|
218
241
|
/**
|
|
219
|
-
* Show warning about dangerous command and wait for user to cancel
|
|
242
|
+
* Show warning about dangerous command and wait for user to cancel.
|
|
243
|
+
* Resolves true after the timeout (continue), or false immediately on CTRL+C (cancel).
|
|
220
244
|
*/
|
|
221
245
|
export async function showDangerousCommandWarning(command, timeoutSeconds = 5) {
|
|
222
|
-
const inquirer = (await import('inquirer')).default;
|
|
223
246
|
const readline = await import('readline');
|
|
224
247
|
console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
|
|
225
248
|
console.log(chalk.red(` Command: ${command}`));
|
|
226
249
|
console.log(chalk.red(' This could potentially harm your system.'));
|
|
227
250
|
console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
|
|
228
|
-
// Set up timeout
|
|
229
|
-
const timeout = setTimeout(() => {
|
|
230
|
-
return true; // Continue after timeout
|
|
231
|
-
}, timeoutSeconds * 1000);
|
|
232
|
-
// Set up readline for immediate cancel
|
|
233
251
|
const rl = readline.createInterface({
|
|
234
252
|
input: process.stdin,
|
|
235
253
|
output: process.stdout,
|
|
236
254
|
});
|
|
237
255
|
return new Promise((resolve) => {
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
rl.close();
|
|
258
|
+
resolve(true);
|
|
259
|
+
}, timeoutSeconds * 1000);
|
|
238
260
|
rl.on('SIGINT', () => {
|
|
239
|
-
clearTimeout(
|
|
261
|
+
clearTimeout(timer);
|
|
240
262
|
rl.close();
|
|
241
263
|
console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
|
|
242
264
|
resolve(false);
|
|
243
265
|
});
|
|
244
|
-
// If timeout expires, resolve without waiting for readline
|
|
245
|
-
setTimeout(() => {
|
|
246
|
-
rl.close();
|
|
247
|
-
resolve(true);
|
|
248
|
-
}, timeoutSeconds * 1000);
|
|
249
266
|
});
|
|
250
267
|
}
|
|
268
|
+
// Security response handling for GUI integration
|
|
269
|
+
export async function handleSecurityResponse(response) {
|
|
270
|
+
// Normalize response
|
|
271
|
+
const normalized = response.trim().toLowerCase();
|
|
272
|
+
// Accept 'y' or 'yes' as positive response
|
|
273
|
+
if (normalized === 'y' || normalized === 'yes') {
|
|
274
|
+
console.log('Security response: ALLOWED');
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
// Reject any other response
|
|
278
|
+
console.log('Security response: DENIED');
|
|
279
|
+
return false;
|
|
280
|
+
}
|
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.
|
|
@@ -30,7 +51,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
30
51
|
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
31
52
|
}
|
|
32
53
|
else {
|
|
33
|
-
|
|
54
|
+
const dirSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
55
|
+
template.variables &&
|
|
56
|
+
template.variables.length > 0 &&
|
|
57
|
+
Object.keys(variables).length > 0));
|
|
58
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
34
59
|
}
|
|
35
60
|
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
36
61
|
}
|
|
@@ -38,7 +63,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
38
63
|
// Single file copy
|
|
39
64
|
if (dryRun) {
|
|
40
65
|
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
41
|
-
|
|
66
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
67
|
+
template.variables &&
|
|
68
|
+
template.variables.length > 0 &&
|
|
69
|
+
Object.keys(variables).length > 0));
|
|
70
|
+
if (drySubstitute) {
|
|
42
71
|
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
43
72
|
}
|
|
44
73
|
if (copyFile.chmod) {
|
|
@@ -49,7 +78,13 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
49
78
|
// Ensure destination directory exists
|
|
50
79
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
51
80
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
52
|
-
if
|
|
81
|
+
// Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
|
|
82
|
+
// If substitute_variables is explicitly false, do not substitute.
|
|
83
|
+
const shouldSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
84
|
+
template.variables &&
|
|
85
|
+
template.variables.length > 0 &&
|
|
86
|
+
Object.keys(variables).length > 0));
|
|
87
|
+
if (shouldSubstitute) {
|
|
53
88
|
content = substituteVariables(content, variables);
|
|
54
89
|
}
|
|
55
90
|
fs.writeFileSync(destPath, content);
|