@garyr/pt-cli 0.33.0 → 0.36.4
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/dist/commands/defaultPostConfigCommand.js +2 -1
- package/dist/commands/initCommand.js +47 -15
- package/dist/commands/learnCommand.js +1 -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 +17 -3
- package/package.json +2 -2
- package/src/commands/defaultPostConfigCommand.ts +2 -1
- package/src/commands/initCommand.ts +50 -14
- package/src/commands/learnCommand.ts +2 -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 +23 -3
|
@@ -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
|
}
|
|
@@ -153,25 +153,26 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
153
153
|
if (fs.existsSync(srcPath)) {
|
|
154
154
|
if (options.dryRun) {
|
|
155
155
|
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
|
-
}
|
|
156
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
160
157
|
continue;
|
|
161
158
|
}
|
|
162
|
-
|
|
159
|
+
let fileContent = fs.readFileSync(srcPath, 'utf-8');
|
|
160
|
+
// Substitute variables in post_copy files if template has variables
|
|
161
|
+
if (template.variables && template.variables.length > 0) {
|
|
162
|
+
const { substituteVariables } = await import('../substitute.js');
|
|
163
|
+
fileContent = substituteVariables(fileContent, variables);
|
|
164
|
+
}
|
|
163
165
|
const destDir = path.dirname(destPath);
|
|
164
166
|
fs.mkdirSync(destDir, { recursive: true });
|
|
165
167
|
fs.writeFileSync(destPath, fileContent);
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
168
|
+
// post_copy files are executables by definition — always chmod
|
|
169
|
+
try {
|
|
170
|
+
// Check if source had execute permissions, otherwise default to 0o755
|
|
171
|
+
const srcStat = fs.statSync(srcPath);
|
|
172
|
+
fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
// chmod not available (Windows)
|
|
175
176
|
}
|
|
176
177
|
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
177
178
|
}
|
|
@@ -190,7 +191,38 @@ export async function init(targetName, destPath, options = {}) {
|
|
|
190
191
|
}
|
|
191
192
|
// Use template post_config tasks
|
|
192
193
|
const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName) || [];
|
|
193
|
-
if (allTasks.length > 0) {
|
|
194
|
+
if (allTasks.length > 0 && !options.skipPostConfig) {
|
|
195
|
+
// SECURITY CHECK: Validate template safety before running post_config tasks
|
|
196
|
+
const { validateTemplateSecurity } = await import('../safety.js');
|
|
197
|
+
const { valid, errors, warnings } = validateTemplateSecurity(template);
|
|
198
|
+
if (!valid) {
|
|
199
|
+
console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
|
|
200
|
+
for (const err of errors) {
|
|
201
|
+
console.error(chalk.red(` - ${err}`));
|
|
202
|
+
}
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
if (warnings.length > 0) {
|
|
206
|
+
console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
|
|
207
|
+
for (const warn of warnings) {
|
|
208
|
+
console.warn(chalk.yellow(` - ${warn}`));
|
|
209
|
+
}
|
|
210
|
+
if (!options.yes) {
|
|
211
|
+
const { proceed } = await inquirer.prompt({
|
|
212
|
+
type: 'confirm',
|
|
213
|
+
name: 'proceed',
|
|
214
|
+
message: chalk.red('Are you sure you want to run these post-config tasks?'),
|
|
215
|
+
default: false
|
|
216
|
+
});
|
|
217
|
+
if (!proceed) {
|
|
218
|
+
console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
194
226
|
// Determine which tasks to include
|
|
195
227
|
let selectedTaskNames = [];
|
|
196
228
|
if (options.skipPostConfig) {
|
|
@@ -9,7 +9,7 @@ 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
|
-
resolvedPath = await downloadAndExtract(sourcePath);
|
|
12
|
+
resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
|
|
13
13
|
}
|
|
14
14
|
else {
|
|
15
15
|
resolvedPath = path.resolve(sourcePath);
|
|
@@ -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
|
@@ -30,7 +30,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
30
30
|
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
31
31
|
}
|
|
32
32
|
else {
|
|
33
|
-
|
|
33
|
+
const dirSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
34
|
+
template.variables &&
|
|
35
|
+
template.variables.length > 0 &&
|
|
36
|
+
Object.keys(variables).length > 0));
|
|
37
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
34
38
|
}
|
|
35
39
|
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
36
40
|
}
|
|
@@ -38,7 +42,11 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
38
42
|
// Single file copy
|
|
39
43
|
if (dryRun) {
|
|
40
44
|
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
41
|
-
|
|
45
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
46
|
+
template.variables &&
|
|
47
|
+
template.variables.length > 0 &&
|
|
48
|
+
Object.keys(variables).length > 0));
|
|
49
|
+
if (drySubstitute) {
|
|
42
50
|
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
43
51
|
}
|
|
44
52
|
if (copyFile.chmod) {
|
|
@@ -49,7 +57,13 @@ export async function processCopyFiles(templateRoot, resolvedDest, template, var
|
|
|
49
57
|
// Ensure destination directory exists
|
|
50
58
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
51
59
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
52
|
-
if
|
|
60
|
+
// Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
|
|
61
|
+
// If substitute_variables is explicitly false, do not substitute.
|
|
62
|
+
const shouldSubstitute = !!(copyFile.substitute_variables === true || (copyFile.substitute_variables === undefined &&
|
|
63
|
+
template.variables &&
|
|
64
|
+
template.variables.length > 0 &&
|
|
65
|
+
Object.keys(variables).length > 0));
|
|
66
|
+
if (shouldSubstitute) {
|
|
53
67
|
content = substituteVariables(content, variables);
|
|
54
68
|
}
|
|
55
69
|
fs.writeFileSync(destPath, content);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@garyr/pt-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.4",
|
|
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
|
}
|
|
@@ -172,26 +172,29 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
172
172
|
if (fs.existsSync(srcPath)) {
|
|
173
173
|
if (options.dryRun) {
|
|
174
174
|
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
|
-
}
|
|
175
|
+
console.log(chalk.gray(` [DRY RUN] Would chmod +x ${file.dest || file.src}`));
|
|
179
176
|
continue;
|
|
180
177
|
}
|
|
181
178
|
|
|
182
|
-
|
|
179
|
+
let fileContent = fs.readFileSync(srcPath, 'utf-8');
|
|
180
|
+
|
|
181
|
+
// Substitute variables in post_copy files if template has variables
|
|
182
|
+
if (template.variables && template.variables.length > 0) {
|
|
183
|
+
const { substituteVariables } = await import('../substitute.js');
|
|
184
|
+
fileContent = substituteVariables(fileContent, variables);
|
|
185
|
+
}
|
|
186
|
+
|
|
183
187
|
const destDir = path.dirname(destPath);
|
|
184
188
|
fs.mkdirSync(destDir, { recursive: true });
|
|
185
189
|
fs.writeFileSync(destPath, fileContent);
|
|
186
190
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
191
|
+
// post_copy files are executables by definition — always chmod
|
|
192
|
+
try {
|
|
193
|
+
// Check if source had execute permissions, otherwise default to 0o755
|
|
194
|
+
const srcStat = fs.statSync(srcPath);
|
|
195
|
+
fs.chmodSync(destPath, srcStat.mode & 0o111 ? srcStat.mode : 0o755);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
// chmod not available (Windows)
|
|
195
198
|
}
|
|
196
199
|
console.log(chalk.green(" ✓ " + (file.dest || file.src)));
|
|
197
200
|
} else {
|
|
@@ -210,7 +213,40 @@ export async function init(targetName: string | undefined, destPath: string | un
|
|
|
210
213
|
// Use template post_config tasks
|
|
211
214
|
const allTasks = template.post_config?.filter(t => !t.type || t.type === typeName!) || [];
|
|
212
215
|
|
|
213
|
-
if (allTasks.length > 0) {
|
|
216
|
+
if (allTasks.length > 0 && !options.skipPostConfig) {
|
|
217
|
+
// SECURITY CHECK: Validate template safety before running post_config tasks
|
|
218
|
+
const { validateTemplateSecurity } = await import('../safety.js');
|
|
219
|
+
const { valid, errors, warnings } = validateTemplateSecurity(template);
|
|
220
|
+
|
|
221
|
+
if (!valid) {
|
|
222
|
+
console.error(chalk.red("\n❌ SECURITY ERROR: Aborting post_config execution due to blocked commands:"));
|
|
223
|
+
for (const err of errors) {
|
|
224
|
+
console.error(chalk.red(` - ${err}`));
|
|
225
|
+
}
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (warnings.length > 0) {
|
|
230
|
+
console.warn(chalk.yellow("\n⚠️ SECURITY WARNING: Post-config contains dangerous or suspicious commands:"));
|
|
231
|
+
for (const warn of warnings) {
|
|
232
|
+
console.warn(chalk.yellow(` - ${warn}`));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!options.yes) {
|
|
236
|
+
const { proceed } = await inquirer.prompt({
|
|
237
|
+
type: 'confirm',
|
|
238
|
+
name: 'proceed',
|
|
239
|
+
message: chalk.red('Are you sure you want to run these post-config tasks?'),
|
|
240
|
+
default: false
|
|
241
|
+
});
|
|
242
|
+
if (!proceed) {
|
|
243
|
+
console.log(chalk.yellow("Post-config tasks aborted by user."));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
console.warn(chalk.yellow("Proceeding anyway (non-interactive mode with auto-confirm enabled)."));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
214
250
|
// Determine which tasks to include
|
|
215
251
|
let selectedTaskNames: string[] = [];
|
|
216
252
|
|
|
@@ -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,7 @@ 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
|
+
resolvedPath = await downloadAndExtract(sourcePath, options.json || false, options.allowUntrusted || false);
|
|
24
25
|
} else {
|
|
25
26
|
resolvedPath = path.resolve(sourcePath);
|
|
26
27
|
}
|
|
@@ -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
|
|
package/src/safety.ts
CHANGED
|
@@ -13,8 +13,8 @@ const BLOCKED_COMMANDS = [
|
|
|
13
13
|
'sudo', 'su', 'su -', 'su root',
|
|
14
14
|
// Disk operations that could destroy data
|
|
15
15
|
'dd', 'mkfs', 'fdisk', 'mount', 'umount',
|
|
16
|
-
// Shell injection patterns
|
|
17
|
-
|
|
16
|
+
// Shell injection patterns - removed from blocklist to allow command chaining.
|
|
17
|
+
// These are validated as warnings instead.
|
|
18
18
|
// Dangerous chmod
|
|
19
19
|
'chmod 777', 'chmod -R 777', 'chmod 666',
|
|
20
20
|
// System commands that could kill processes
|
|
@@ -28,8 +28,6 @@ const BLOCKED_COMMANDS = [
|
|
|
28
28
|
// === DANGEROUS PATTERNS ===
|
|
29
29
|
// Commands that should trigger a warning but are NOT blocked
|
|
30
30
|
const DANGEROUS_PATTERNS = [
|
|
31
|
-
// Destructive file operations
|
|
32
|
-
'rm -rf', 'rm -r', 'rm --no-preserve-root', 'rm -rf /',
|
|
33
31
|
// Remote downloads + execution
|
|
34
32
|
'curl', 'wget', 'wget -O', 'curl |', 'wget |',
|
|
35
33
|
// Script execution
|
|
@@ -83,6 +81,31 @@ export function isBlockedCommand(command: string): boolean {
|
|
|
83
81
|
* Check if a command should trigger a warning (but is allowed)
|
|
84
82
|
*/
|
|
85
83
|
export function isDangerousCommand(command: string): boolean {
|
|
84
|
+
// Check for destructive file operations targeting absolute paths
|
|
85
|
+
// Regex matches 'rm', 'rmdir', or Windows 'del' followed by optional flags and then an absolute path.
|
|
86
|
+
// Absolute path matches:
|
|
87
|
+
// - Unix: starting with '/' (e.g. /tmp, /usr)
|
|
88
|
+
// - Windows: starting with drive letter (e.g. C:\) or UNC path (\\) or drive-relative '\'
|
|
89
|
+
const parts = command.split(/\s+/);
|
|
90
|
+
const rmIndex = parts.findIndex(p => p === 'rm' || p === 'rmdir' || p === 'del');
|
|
91
|
+
if (rmIndex !== -1) {
|
|
92
|
+
// Check subsequent arguments
|
|
93
|
+
for (let i = rmIndex + 1; i < parts.length; i++) {
|
|
94
|
+
const arg = parts[i];
|
|
95
|
+
if (!arg) continue;
|
|
96
|
+
// Skip flags (starting with - or /flag on Windows)
|
|
97
|
+
if (arg.startsWith('-') || (process.platform === 'win32' && arg.startsWith('/'))) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Check absolute path patterns
|
|
101
|
+
const isAbsolute = arg.startsWith('/') ||
|
|
102
|
+
(process.platform === 'win32' && (/^[a-zA-Z]:\\/.test(arg) || arg.startsWith('\\\\') || arg.startsWith('\\')));
|
|
103
|
+
if (isAbsolute) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
for (const pattern of DANGEROUS_PATTERNS) {
|
|
87
110
|
if (command.includes(pattern)) {
|
|
88
111
|
return true;
|
|
@@ -164,35 +187,35 @@ export function isTrustedSource(url: string, trustedSources: string[] = DEFAULT_
|
|
|
164
187
|
}
|
|
165
188
|
|
|
166
189
|
/**
|
|
167
|
-
*
|
|
190
|
+
* Rate limiting: track the last execution timestamp per command hash
|
|
168
191
|
*/
|
|
169
|
-
const
|
|
192
|
+
const lastExecutionTimes = new Map<string, number>();
|
|
170
193
|
const RATE_LIMIT_MS = 1000; // 1 second between identical commands
|
|
171
194
|
|
|
172
195
|
export function canExecute(command: string, maxCommandsPerRun: number = 50): boolean {
|
|
173
|
-
// Check total
|
|
174
|
-
const totalExecuted =
|
|
196
|
+
// Check total commands executed this run
|
|
197
|
+
const totalExecuted = lastExecutionTimes.size;
|
|
175
198
|
if (totalExecuted >= maxCommandsPerRun) {
|
|
176
199
|
return false;
|
|
177
200
|
}
|
|
178
201
|
|
|
179
202
|
// Check rate limit for this specific command
|
|
180
203
|
const hash = crypto.createHash('md5').update(command).digest('hex');
|
|
181
|
-
const
|
|
182
|
-
if (Date.now() -
|
|
204
|
+
const lastTime = lastExecutionTimes.get(hash) || 0;
|
|
205
|
+
if (Date.now() - lastTime < RATE_LIMIT_MS) {
|
|
183
206
|
return false;
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
//
|
|
187
|
-
|
|
209
|
+
// Record execution timestamp
|
|
210
|
+
lastExecutionTimes.set(hash, Date.now());
|
|
188
211
|
return true;
|
|
189
212
|
}
|
|
190
213
|
|
|
191
214
|
/**
|
|
192
|
-
* Reset execution
|
|
215
|
+
* Reset execution timestamps (for testing or between runs)
|
|
193
216
|
*/
|
|
194
217
|
export function resetExecutionCounts(): void {
|
|
195
|
-
|
|
218
|
+
lastExecutionTimes.clear();
|
|
196
219
|
}
|
|
197
220
|
|
|
198
221
|
/**
|
|
@@ -260,10 +283,10 @@ export function getSecurityPolicy(configPath?: string): SecurityPolicy {
|
|
|
260
283
|
}
|
|
261
284
|
|
|
262
285
|
/**
|
|
263
|
-
* Show warning about dangerous command and wait for user to cancel
|
|
286
|
+
* Show warning about dangerous command and wait for user to cancel.
|
|
287
|
+
* Resolves true after the timeout (continue), or false immediately on CTRL+C (cancel).
|
|
264
288
|
*/
|
|
265
289
|
export async function showDangerousCommandWarning(command: string, timeoutSeconds: number = 5): Promise<boolean> {
|
|
266
|
-
const inquirer = (await import('inquirer')).default;
|
|
267
290
|
const readline = await import('readline');
|
|
268
291
|
|
|
269
292
|
console.log(chalk.red('\n⚠️ DANGEROUS COMMAND DETECTED'));
|
|
@@ -271,29 +294,38 @@ export async function showDangerousCommandWarning(command: string, timeoutSecond
|
|
|
271
294
|
console.log(chalk.red(' This could potentially harm your system.'));
|
|
272
295
|
console.log(chalk.yellow(` Press CTRL+C to cancel, or wait ${timeoutSeconds}s to continue...`));
|
|
273
296
|
|
|
274
|
-
// Set up timeout
|
|
275
|
-
const timeout = setTimeout(() => {
|
|
276
|
-
return true; // Continue after timeout
|
|
277
|
-
}, timeoutSeconds * 1000);
|
|
278
|
-
|
|
279
|
-
// Set up readline for immediate cancel
|
|
280
297
|
const rl = readline.createInterface({
|
|
281
298
|
input: process.stdin,
|
|
282
299
|
output: process.stdout,
|
|
283
300
|
});
|
|
284
301
|
|
|
285
302
|
return new Promise((resolve) => {
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
rl.close();
|
|
305
|
+
resolve(true);
|
|
306
|
+
}, timeoutSeconds * 1000);
|
|
307
|
+
|
|
286
308
|
rl.on('SIGINT', () => {
|
|
287
|
-
clearTimeout(
|
|
309
|
+
clearTimeout(timer);
|
|
288
310
|
rl.close();
|
|
289
311
|
console.log(chalk.yellow('\n⚠️ Command cancelled by user'));
|
|
290
312
|
resolve(false);
|
|
291
313
|
});
|
|
292
|
-
|
|
293
|
-
// If timeout expires, resolve without waiting for readline
|
|
294
|
-
setTimeout(() => {
|
|
295
|
-
rl.close();
|
|
296
|
-
resolve(true);
|
|
297
|
-
}, timeoutSeconds * 1000);
|
|
298
314
|
});
|
|
299
315
|
}
|
|
316
|
+
|
|
317
|
+
// Security response handling for GUI integration
|
|
318
|
+
export async function handleSecurityResponse(response: string): Promise<boolean> {
|
|
319
|
+
// Normalize response
|
|
320
|
+
const normalized = response.trim().toLowerCase();
|
|
321
|
+
|
|
322
|
+
// Accept 'y' or 'yes' as positive response
|
|
323
|
+
if (normalized === 'y' || normalized === 'yes') {
|
|
324
|
+
console.log('Security response: ALLOWED');
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Reject any other response
|
|
329
|
+
console.log('Security response: DENIED');
|
|
330
|
+
return false;
|
|
331
|
+
}
|
package/src/substitute.ts
CHANGED
|
@@ -44,14 +44,26 @@ export async function processCopyFiles(
|
|
|
44
44
|
if (dryRun) {
|
|
45
45
|
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
46
46
|
} else {
|
|
47
|
-
|
|
47
|
+
const dirSubstitute = !!(copyFile.substitute_variables === true || (
|
|
48
|
+
copyFile.substitute_variables === undefined &&
|
|
49
|
+
template.variables &&
|
|
50
|
+
template.variables.length > 0 &&
|
|
51
|
+
Object.keys(variables).length > 0
|
|
52
|
+
));
|
|
53
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
48
54
|
}
|
|
49
55
|
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
50
56
|
} else {
|
|
51
57
|
// Single file copy
|
|
52
58
|
if (dryRun) {
|
|
53
59
|
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
54
|
-
|
|
60
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (
|
|
61
|
+
copyFile.substitute_variables === undefined &&
|
|
62
|
+
template.variables &&
|
|
63
|
+
template.variables.length > 0 &&
|
|
64
|
+
Object.keys(variables).length > 0
|
|
65
|
+
));
|
|
66
|
+
if (drySubstitute) {
|
|
55
67
|
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
56
68
|
}
|
|
57
69
|
if (copyFile.chmod) {
|
|
@@ -64,7 +76,15 @@ export async function processCopyFiles(
|
|
|
64
76
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
65
77
|
|
|
66
78
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
67
|
-
if
|
|
79
|
+
// Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
|
|
80
|
+
// If substitute_variables is explicitly false, do not substitute.
|
|
81
|
+
const shouldSubstitute = !!(copyFile.substitute_variables === true || (
|
|
82
|
+
copyFile.substitute_variables === undefined &&
|
|
83
|
+
template.variables &&
|
|
84
|
+
template.variables.length > 0 &&
|
|
85
|
+
Object.keys(variables).length > 0
|
|
86
|
+
));
|
|
87
|
+
if (shouldSubstitute) {
|
|
68
88
|
content = substituteVariables(content, variables);
|
|
69
89
|
}
|
|
70
90
|
|