@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/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
|
@@ -7,14 +7,39 @@ import { sanitizePath } from './config.js';
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Replaces all {{var}} patterns in the content with values from the variables object.
|
|
10
|
+
* Supports nested variable expansion - if a variable's value contains {{other_var}},
|
|
11
|
+
* it will be expanded iteratively until no more placeholders remain or maxIterations is reached.
|
|
10
12
|
*/
|
|
11
13
|
export function substituteVariables(
|
|
12
14
|
content: string,
|
|
13
|
-
variables: Record<string, string
|
|
15
|
+
variables: Record<string, string>,
|
|
16
|
+
maxIterations: number = 10
|
|
14
17
|
): string {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
let result = content;
|
|
19
|
+
let iteration = 0;
|
|
20
|
+
|
|
21
|
+
// Keep expanding until no more placeholders remain or we hit the limit
|
|
22
|
+
while (/\{\{[^}]+\}\}/.test(result) && iteration < maxIterations) {
|
|
23
|
+
// Use a more complex regex that captures the full placeholder including spaces
|
|
24
|
+
result = result.replace(/(\{\{\s*)(\w+)(\s*\}\})/g, (_, prefix, varName, suffix) => {
|
|
25
|
+
const val = variables[varName];
|
|
26
|
+
// If variable not found, leave placeholder as-is with original spacing
|
|
27
|
+
if (val === undefined) {
|
|
28
|
+
return `${prefix}${varName}${suffix}`;
|
|
29
|
+
}
|
|
30
|
+
// Return the value (which may contain more placeholders to expand)
|
|
31
|
+
return val;
|
|
32
|
+
});
|
|
33
|
+
iteration++;
|
|
34
|
+
|
|
35
|
+
// Prevent infinite loops by checking if we're stuck
|
|
36
|
+
if (iteration > 1 && result === content) {
|
|
37
|
+
console.warn(chalk.yellow(`Warning: Potential infinite loop detected in variable expansion, stopping after ${iteration} iterations`));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result;
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
/**
|
|
@@ -44,14 +69,26 @@ export async function processCopyFiles(
|
|
|
44
69
|
if (dryRun) {
|
|
45
70
|
console.log(chalk.gray(` [DRY RUN] Would recursively copy directory ${copyFile.src} → ${copyFile.dest}`));
|
|
46
71
|
} else {
|
|
47
|
-
|
|
72
|
+
const dirSubstitute = !!(copyFile.substitute_variables === true || (
|
|
73
|
+
copyFile.substitute_variables === undefined &&
|
|
74
|
+
template.variables &&
|
|
75
|
+
template.variables.length > 0 &&
|
|
76
|
+
Object.keys(variables).length > 0
|
|
77
|
+
));
|
|
78
|
+
copyDirRecursive(srcPath, destPath, variables, dirSubstitute, copyFile.chmod);
|
|
48
79
|
}
|
|
49
80
|
console.log(chalk.green(` ✓ ${copyFile.dest} (recursive)`));
|
|
50
81
|
} else {
|
|
51
82
|
// Single file copy
|
|
52
83
|
if (dryRun) {
|
|
53
84
|
console.log(chalk.gray(` [DRY RUN] Would copy ${copyFile.src} → ${copyFile.dest}`));
|
|
54
|
-
|
|
85
|
+
const drySubstitute = !!(copyFile.substitute_variables === true || (
|
|
86
|
+
copyFile.substitute_variables === undefined &&
|
|
87
|
+
template.variables &&
|
|
88
|
+
template.variables.length > 0 &&
|
|
89
|
+
Object.keys(variables).length > 0
|
|
90
|
+
));
|
|
91
|
+
if (drySubstitute) {
|
|
55
92
|
console.log(chalk.gray(` [DRY RUN] Would substitute variables in ${copyFile.dest}`));
|
|
56
93
|
}
|
|
57
94
|
if (copyFile.chmod) {
|
|
@@ -64,7 +101,15 @@ export async function processCopyFiles(
|
|
|
64
101
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
65
102
|
|
|
66
103
|
let content = fs.readFileSync(srcPath, 'utf-8');
|
|
67
|
-
if
|
|
104
|
+
// Default to substituting if substitute_variables is true, OR if it's undefined AND the template defines variables.
|
|
105
|
+
// If substitute_variables is explicitly false, do not substitute.
|
|
106
|
+
const shouldSubstitute = !!(copyFile.substitute_variables === true || (
|
|
107
|
+
copyFile.substitute_variables === undefined &&
|
|
108
|
+
template.variables &&
|
|
109
|
+
template.variables.length > 0 &&
|
|
110
|
+
Object.keys(variables).length > 0
|
|
111
|
+
));
|
|
112
|
+
if (shouldSubstitute) {
|
|
68
113
|
content = substituteVariables(content, variables);
|
|
69
114
|
}
|
|
70
115
|
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// Force a temporary home directory for testing before importing anything from the CLI
|
|
7
|
+
const testHome = path.join(process.cwd(), '.test-home-env');
|
|
8
|
+
process.env.HOME = testHome;
|
|
9
|
+
|
|
10
|
+
// Import the init command to test the env scanning functionality
|
|
11
|
+
import { init } from '../src/commands/initCommand.js';
|
|
12
|
+
import { loadConfig, saveConfig, PtConfig, getConfigPath } from '../src/config.js';
|
|
13
|
+
|
|
14
|
+
// Helper to clean up test directories
|
|
15
|
+
function cleanup(...paths: string[]) {
|
|
16
|
+
for (const p of paths) {
|
|
17
|
+
if (fs.existsSync(p)) {
|
|
18
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper to set up a config with a template for testing
|
|
24
|
+
function setupTestConfig(templateName: string, template: any): PtConfig {
|
|
25
|
+
const config: PtConfig = {
|
|
26
|
+
version: '3.0',
|
|
27
|
+
templates: {
|
|
28
|
+
[templateName]: template
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
saveConfig(config);
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('init scans parent directories for .env files and pre-fills variables', async () => {
|
|
36
|
+
const parentEnvDir = path.join(process.cwd(), 'test-parent-env');
|
|
37
|
+
const projectDest = path.join(parentEnvDir, 'test-env-project');
|
|
38
|
+
const templateRoot = path.join(process.cwd(), 'test-env-tpl-root');
|
|
39
|
+
cleanup(projectDest, templateRoot, testHome);
|
|
40
|
+
|
|
41
|
+
// Create template root with a file containing variables
|
|
42
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
43
|
+
fs.writeFileSync(
|
|
44
|
+
path.join(templateRoot, 'README.md'),
|
|
45
|
+
'# {{ project_name }}\n\nBy {{ author }}\n'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Set up config with template that has variables and copy_files
|
|
49
|
+
setupTestConfig('env-tpl', {
|
|
50
|
+
description: 'Template with variables',
|
|
51
|
+
templateRoot: templateRoot,
|
|
52
|
+
folders: [],
|
|
53
|
+
variables: [
|
|
54
|
+
{ name: 'project_name', prompt: 'Project name:', required: true },
|
|
55
|
+
{ name: 'author', prompt: 'Author:', default: 'Unknown' }
|
|
56
|
+
],
|
|
57
|
+
copy_files: [
|
|
58
|
+
{ src: 'README.md', dest: 'README.md', substitute_variables: true }
|
|
59
|
+
]
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Create parent directory with .env file containing matching variables
|
|
63
|
+
fs.mkdirSync(parentEnvDir, { recursive: true });
|
|
64
|
+
fs.writeFileSync(
|
|
65
|
+
path.join(parentEnvDir, '.env'),
|
|
66
|
+
`project_name=EnvProject\nauthor=EnvAuthor\n`
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Run init from within the parent directory
|
|
70
|
+
await init('env-tpl', projectDest, {
|
|
71
|
+
yes: true,
|
|
72
|
+
skipPostConfig: true
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Verify .env variables were used
|
|
76
|
+
const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
|
|
77
|
+
assert.ok(readme.includes('EnvProject'), 'README should contain project_name from .env');
|
|
78
|
+
assert.ok(readme.includes('EnvAuthor'), 'README should contain author from .env');
|
|
79
|
+
assert.ok(!readme.includes('{{ project_name }}'), 'README should NOT contain variable placeholder');
|
|
80
|
+
|
|
81
|
+
cleanup(projectDest, templateRoot, parentEnvDir, testHome);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('init --vars overrides .env variables', async () => {
|
|
85
|
+
const parentEnvDir = path.join(process.cwd(), 'test-parent-override');
|
|
86
|
+
const projectDest = path.join(parentEnvDir, 'test-override-project');
|
|
87
|
+
const templateRoot = path.join(process.cwd(), 'test-override-tpl-root');
|
|
88
|
+
cleanup(projectDest, templateRoot, testHome);
|
|
89
|
+
|
|
90
|
+
// Create template root with a file containing variables
|
|
91
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
92
|
+
fs.writeFileSync(
|
|
93
|
+
path.join(templateRoot, 'config.txt'),
|
|
94
|
+
'name={{ project_name }}\n'
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Set up config with template that has variables and copy_files
|
|
98
|
+
setupTestConfig('override-tpl', {
|
|
99
|
+
description: 'Template with variables',
|
|
100
|
+
templateRoot: templateRoot,
|
|
101
|
+
folders: [],
|
|
102
|
+
variables: [
|
|
103
|
+
{ name: 'project_name', prompt: 'Project name:', required: true }
|
|
104
|
+
],
|
|
105
|
+
copy_files: [
|
|
106
|
+
{ src: 'config.txt', dest: 'config.txt', substitute_variables: true }
|
|
107
|
+
]
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Create parent directory with .env file
|
|
111
|
+
fs.mkdirSync(parentEnvDir, { recursive: true });
|
|
112
|
+
fs.writeFileSync(
|
|
113
|
+
path.join(parentEnvDir, '.env'),
|
|
114
|
+
`project_name=EnvProject\n`
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Run init with --vars to override .env value
|
|
118
|
+
await init('override-tpl', projectDest, {
|
|
119
|
+
yes: true,
|
|
120
|
+
skipPostConfig: true,
|
|
121
|
+
vars: 'project_name=CLIProject'
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Verify --vars value was used instead of .env value
|
|
125
|
+
const configContent = fs.readFileSync(path.join(projectDest, 'config.txt'), 'utf-8');
|
|
126
|
+
assert.ok(configContent.includes('CLIProject'), 'config.txt should contain --vars value');
|
|
127
|
+
assert.ok(!configContent.includes('EnvProject'), 'config.txt should NOT contain .env value');
|
|
128
|
+
|
|
129
|
+
cleanup(projectDest, templateRoot, parentEnvDir, testHome);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('init uses .env from immediate parent directory', async () => {
|
|
133
|
+
const parentEnvDir = path.join(process.cwd(), 'test-immediate-parent');
|
|
134
|
+
const projectDest = path.join(parentEnvDir, 'test-immediate-parent-project');
|
|
135
|
+
const templateRoot = path.join(process.cwd(), 'test-immediate-parent-tpl-root');
|
|
136
|
+
cleanup(projectDest, templateRoot, testHome);
|
|
137
|
+
|
|
138
|
+
// Create template root with a file containing variables
|
|
139
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
140
|
+
fs.writeFileSync(
|
|
141
|
+
path.join(templateRoot, 'README.md'),
|
|
142
|
+
'# {{ project_name }}\n'
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Set up config with template that has variables and copy_files
|
|
146
|
+
setupTestConfig('immediate-parent-tpl', {
|
|
147
|
+
description: 'Template with variables',
|
|
148
|
+
templateRoot: templateRoot,
|
|
149
|
+
folders: [],
|
|
150
|
+
variables: [
|
|
151
|
+
{ name: 'project_name', prompt: 'Project name:', required: true }
|
|
152
|
+
],
|
|
153
|
+
copy_files: [
|
|
154
|
+
{ src: 'README.md', dest: 'README.md', substitute_variables: true }
|
|
155
|
+
]
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Create immediate parent directory with .env file
|
|
159
|
+
fs.mkdirSync(parentEnvDir, { recursive: true });
|
|
160
|
+
fs.writeFileSync(
|
|
161
|
+
path.join(parentEnvDir, '.env'),
|
|
162
|
+
`project_name=ImmediateParent\n`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Run init from within the parent directory
|
|
166
|
+
await init('immediate-parent-tpl', projectDest, {
|
|
167
|
+
yes: true,
|
|
168
|
+
skipPostConfig: true
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Verify .env variable was used
|
|
172
|
+
const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
|
|
173
|
+
assert.ok(readme.includes('ImmediateParent'), 'README should contain project_name from immediate parent .env');
|
|
174
|
+
|
|
175
|
+
cleanup(projectDest, templateRoot, parentEnvDir, testHome);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('init scans multiple parent directories for .env files', async () => {
|
|
179
|
+
const topEnvDir = path.join(process.cwd(), 'test-multi-parent');
|
|
180
|
+
const midEnvDir = path.join(topEnvDir, 'test-mid-env');
|
|
181
|
+
const projectDest = path.join(midEnvDir, 'test-multi-parent-project');
|
|
182
|
+
const templateRoot = path.join(process.cwd(), 'test-multi-parent-tpl-root');
|
|
183
|
+
cleanup(projectDest, templateRoot, testHome);
|
|
184
|
+
|
|
185
|
+
// Create template root with a file containing variables
|
|
186
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
187
|
+
fs.writeFileSync(
|
|
188
|
+
path.join(templateRoot, 'README.md'),
|
|
189
|
+
'# {{ project_name }}\n\nBy {{ author }}\n'
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Set up config with template that has variables and copy_files
|
|
193
|
+
setupTestConfig('multi-parent-tpl', {
|
|
194
|
+
description: 'Template with variables',
|
|
195
|
+
templateRoot: templateRoot,
|
|
196
|
+
folders: [],
|
|
197
|
+
variables: [
|
|
198
|
+
{ name: 'project_name', prompt: 'Project name:', required: true },
|
|
199
|
+
{ name: 'author', prompt: 'Author:', default: 'Unknown' }
|
|
200
|
+
],
|
|
201
|
+
copy_files: [
|
|
202
|
+
{ src: 'README.md', dest: 'README.md', substitute_variables: true }
|
|
203
|
+
]
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Create nested directory structure with .env files at different levels
|
|
207
|
+
fs.mkdirSync(topEnvDir, { recursive: true });
|
|
208
|
+
fs.mkdirSync(midEnvDir, { recursive: true });
|
|
209
|
+
|
|
210
|
+
// .env file at the top level with project_name
|
|
211
|
+
fs.writeFileSync(
|
|
212
|
+
path.join(topEnvDir, '.env'),
|
|
213
|
+
`project_name=TopLevel\n`
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// .env file at the middle level with author
|
|
217
|
+
fs.writeFileSync(
|
|
218
|
+
path.join(midEnvDir, '.env'),
|
|
219
|
+
`author=MidLevelAuthor\n`
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Run init from the deepest directory
|
|
223
|
+
await init('multi-parent-tpl', projectDest, {
|
|
224
|
+
yes: true,
|
|
225
|
+
skipPostConfig: true
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Verify .env variables from different levels were used
|
|
229
|
+
const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
|
|
230
|
+
assert.ok(readme.includes('TopLevel'), 'README should contain project_name from top level .env');
|
|
231
|
+
assert.ok(readme.includes('MidLevelAuthor'), 'README should contain author from middle level .env');
|
|
232
|
+
|
|
233
|
+
cleanup(projectDest, templateRoot, topEnvDir, midEnvDir, testHome);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('init uses .env values with quoted strings', async () => {
|
|
237
|
+
const parentEnvDir = path.join(process.cwd(), 'test-quoted-parent');
|
|
238
|
+
const projectDest = path.join(parentEnvDir, 'test-quoted-strings-project');
|
|
239
|
+
const templateRoot = path.join(process.cwd(), 'test-quoted-strings-tpl-root');
|
|
240
|
+
cleanup(projectDest, templateRoot, testHome);
|
|
241
|
+
|
|
242
|
+
// Create template root with a file containing variables
|
|
243
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
244
|
+
fs.writeFileSync(
|
|
245
|
+
path.join(templateRoot, 'README.md'),
|
|
246
|
+
'# {{ project_name }}\n\nBy {{ author }}\n'
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Set up config with template that has variables and copy_files
|
|
250
|
+
setupTestConfig('quoted-strings-tpl', {
|
|
251
|
+
description: 'Template with variables',
|
|
252
|
+
templateRoot: templateRoot,
|
|
253
|
+
folders: [],
|
|
254
|
+
variables: [
|
|
255
|
+
{ name: 'project_name', prompt: 'Project name:', required: true },
|
|
256
|
+
{ name: 'author', prompt: 'Author:', default: 'Unknown' }
|
|
257
|
+
],
|
|
258
|
+
copy_files: [
|
|
259
|
+
{ src: 'README.md', dest: 'README.md', substitute_variables: true }
|
|
260
|
+
]
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Create parent directory with .env file containing quoted strings
|
|
264
|
+
fs.mkdirSync(parentEnvDir, { recursive: true });
|
|
265
|
+
fs.writeFileSync(
|
|
266
|
+
path.join(parentEnvDir, '.env'),
|
|
267
|
+
`project_name="My Quoted Project"\nauthor='Quoted Author'\n`
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Run init from within the parent directory
|
|
271
|
+
await init('quoted-strings-tpl', projectDest, {
|
|
272
|
+
yes: true,
|
|
273
|
+
skipPostConfig: true
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Verify quoted strings were parsed correctly
|
|
277
|
+
const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
|
|
278
|
+
assert.ok(readme.includes('My Quoted Project'), 'README should contain project_name without quotes');
|
|
279
|
+
assert.ok(readme.includes('Quoted Author'), 'README should contain author without quotes');
|
|
280
|
+
assert.ok(!readme.includes('"My Quoted Project"'), 'README should NOT contain quotes around project_name');
|
|
281
|
+
assert.ok(!readme.includes("'Quoted Author'"), 'README should NOT contain quotes around author');
|
|
282
|
+
|
|
283
|
+
cleanup(projectDest, templateRoot, parentEnvDir, testHome);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('init uses .env values with comments and empty lines', async () => {
|
|
287
|
+
const parentEnvDir = path.join(process.cwd(), 'test-comments-parent');
|
|
288
|
+
const projectDest = path.join(parentEnvDir, 'test-comments-project');
|
|
289
|
+
const templateRoot = path.join(process.cwd(), 'test-comments-tpl-root');
|
|
290
|
+
cleanup(projectDest, templateRoot, testHome);
|
|
291
|
+
|
|
292
|
+
// Create template root with a file containing variables
|
|
293
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
294
|
+
fs.writeFileSync(
|
|
295
|
+
path.join(templateRoot, 'README.md'),
|
|
296
|
+
'# {{ project_name }}\n'
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Set up config with template that has variables and copy_files
|
|
300
|
+
setupTestConfig('comments-tpl', {
|
|
301
|
+
description: 'Template with variables',
|
|
302
|
+
templateRoot: templateRoot,
|
|
303
|
+
folders: [],
|
|
304
|
+
variables: [
|
|
305
|
+
{ name: 'project_name', prompt: 'Project name:', required: true }
|
|
306
|
+
],
|
|
307
|
+
copy_files: [
|
|
308
|
+
{ src: 'README.md', dest: 'README.md', substitute_variables: true }
|
|
309
|
+
]
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Create parent directory with .env file containing comments and empty lines
|
|
313
|
+
fs.mkdirSync(parentEnvDir, { recursive: true });
|
|
314
|
+
fs.writeFileSync(
|
|
315
|
+
path.join(parentEnvDir, '.env'),
|
|
316
|
+
`# This is a comment\n\nproject_name=CommentProject\n\n# Another comment\n`
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// Run init from within the parent directory
|
|
320
|
+
await init('comments-tpl', projectDest, {
|
|
321
|
+
yes: true,
|
|
322
|
+
skipPostConfig: true
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Verify .env variable was used (comments and empty lines should be ignored)
|
|
326
|
+
const readme = fs.readFileSync(path.join(projectDest, 'README.md'), 'utf-8');
|
|
327
|
+
assert.ok(readme.includes('CommentProject'), 'README should contain project_name from .env');
|
|
328
|
+
|
|
329
|
+
cleanup(projectDest, templateRoot, parentEnvDir, testHome);
|
|
330
|
+
});
|