prquicktest 1.1.0 → 2.0.1
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 +47 -24
- package/bin/prquicktest.mjs +131 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,25 +22,6 @@ prquicktest <file.md> # Run from local file
|
|
|
22
22
|
prquicktest -y <pr-url> # Skip confirmation prompt
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
## Security
|
|
26
|
-
|
|
27
|
-
Before executing any code, prquicktest shows a preview of all code blocks and asks for confirmation:
|
|
28
|
-
|
|
29
|
-
```
|
|
30
|
-
Found 2 code block(s) to execute:
|
|
31
|
-
|
|
32
|
-
[bash]
|
|
33
|
-
npm test
|
|
34
|
-
|
|
35
|
-
[bash]
|
|
36
|
-
npm run build
|
|
37
|
-
|
|
38
|
-
⚠ WARNING: This will execute code on your machine.
|
|
39
|
-
Proceed? [y/N]
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Use `-y` or `--yes` to skip the prompt (for trusted sources only).
|
|
43
|
-
|
|
44
25
|
## How It Works
|
|
45
26
|
|
|
46
27
|
Only executes code blocks found under a **Testing**, **Tests**, or **Test** header (any level: `#`, `##`, `###`, etc.). The section ends when another header of equal or higher level appears.
|
|
@@ -70,13 +51,55 @@ This section is ignored.
|
|
|
70
51
|
|
|
71
52
|
Running `prquicktest <pr-url>` will only execute the two bash blocks under "Testing".
|
|
72
53
|
|
|
54
|
+
## Shared Environment
|
|
55
|
+
|
|
56
|
+
All code blocks run sequentially in a **single persistent shell session**. Environment variables, working directory changes, and other shell state automatically persist across blocks.
|
|
57
|
+
|
|
58
|
+
### Export-only blocks
|
|
59
|
+
|
|
60
|
+
Blocks that contain only `export` statements run automatically without prompting. This is useful for setup blocks that configure environment variables for subsequent tests.
|
|
61
|
+
|
|
62
|
+
### Example with shared environment
|
|
63
|
+
|
|
64
|
+
```markdown
|
|
65
|
+
## Testing
|
|
66
|
+
|
|
67
|
+
### Setup environment
|
|
68
|
+
```bash
|
|
69
|
+
export BASE_URL="https://my-preview.onrender.com"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Test 1: Check health endpoint
|
|
73
|
+
```bash
|
|
74
|
+
curl -s "$BASE_URL/health" | jq .
|
|
75
|
+
```
|
|
76
|
+
check: Response status is 200
|
|
77
|
+
|
|
78
|
+
### Test 2: Check API
|
|
79
|
+
```bash
|
|
80
|
+
curl -s "$BASE_URL/api/v1/status" | jq .
|
|
81
|
+
```
|
|
82
|
+
check: Response contains "ok"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The setup block runs silently and sets `BASE_URL`, which is then available in both test blocks.
|
|
86
|
+
|
|
73
87
|
## Supported Languages
|
|
74
88
|
|
|
75
|
-
| Language | Aliases |
|
|
76
|
-
|
|
77
|
-
| Bash | `bash`, `sh`, `shell
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
| Language | Aliases |
|
|
90
|
+
|----------|---------|
|
|
91
|
+
| Bash | `bash`, `sh`, `shell`, `zsh` |
|
|
92
|
+
|
|
93
|
+
Code blocks with other language tags (e.g., `javascript`, `python`) are skipped with a warning.
|
|
94
|
+
|
|
95
|
+
## Security
|
|
96
|
+
|
|
97
|
+
**prquicktest executes arbitrary code from PR descriptions on your machine.** Review all code blocks before running them.
|
|
98
|
+
|
|
99
|
+
- Each code block is shown before execution, and confirmation is required (unless `-y` is used).
|
|
100
|
+
- All blocks execute in a single shell session. State set by earlier blocks (environment variables, working directory, aliases) affects all subsequent blocks. A malicious PR could set variables like `PATH`, `LD_PRELOAD`, or `NODE_OPTIONS` in an early block to influence later blocks.
|
|
101
|
+
- The shared shell doesn't fundamentally change the threat model — if you run the code, you're already trusting it. The `-y` flag is especially dangerous since it skips per-block confirmation.
|
|
102
|
+
- Export-only blocks (containing only `export` statements) run automatically without prompting. Review the PR description to ensure these are safe.
|
|
80
103
|
|
|
81
104
|
## Requirements
|
|
82
105
|
|
package/bin/prquicktest.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { spawn, spawnSync } from 'child_process';
|
|
12
12
|
import { readFileSync } from 'fs';
|
|
13
13
|
import { createInterface } from 'readline';
|
|
14
|
+
import { randomBytes } from 'crypto';
|
|
14
15
|
|
|
15
16
|
const colors = {
|
|
16
17
|
reset: '\x1b[0m',
|
|
@@ -21,17 +22,7 @@ const colors = {
|
|
|
21
22
|
red: '\x1b[31m',
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
-
bash: { cmd: 'bash', args: ['-c'] },
|
|
26
|
-
sh: { cmd: 'sh', args: ['-c'] },
|
|
27
|
-
shell: { cmd: 'bash', args: ['-c'] },
|
|
28
|
-
javascript: { cmd: 'node', args: ['-e'] },
|
|
29
|
-
js: { cmd: 'node', args: ['-e'] },
|
|
30
|
-
node: { cmd: 'node', args: ['-e'] },
|
|
31
|
-
python: { cmd: 'python3', args: ['-c'] },
|
|
32
|
-
py: { cmd: 'python3', args: ['-c'] },
|
|
33
|
-
python3: { cmd: 'python3', args: ['-c'] },
|
|
34
|
-
};
|
|
25
|
+
const shellLangs = new Set(['bash', 'sh', 'shell', 'zsh']);
|
|
35
26
|
|
|
36
27
|
function isGitHubPrUrl(input) {
|
|
37
28
|
return /^https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/.test(input);
|
|
@@ -179,7 +170,7 @@ function parseMarkdown(content) {
|
|
|
179
170
|
return blocks;
|
|
180
171
|
}
|
|
181
172
|
|
|
182
|
-
function
|
|
173
|
+
function promptUser(question) {
|
|
183
174
|
const rl = createInterface({
|
|
184
175
|
input: process.stdin,
|
|
185
176
|
output: process.stdout,
|
|
@@ -193,32 +184,102 @@ function prompt(question) {
|
|
|
193
184
|
});
|
|
194
185
|
}
|
|
195
186
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Check if a code block contains only export statements (and comments/blank lines).
|
|
189
|
+
* Returns the list of exported variable names, or null if not export-only.
|
|
190
|
+
*/
|
|
191
|
+
function getExportOnlyVars(code) {
|
|
192
|
+
const lines = code.split('\n');
|
|
193
|
+
const varNames = [];
|
|
194
|
+
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
|
199
198
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
const exportMatch = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
200
|
+
if (exportMatch) {
|
|
201
|
+
varNames.push(exportMatch[1]);
|
|
202
|
+
} else {
|
|
203
|
+
return null;
|
|
204
204
|
}
|
|
205
|
+
}
|
|
205
206
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
return varNames.length > 0 ? varNames : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Spawn a persistent bash shell for running code blocks.
|
|
212
|
+
*/
|
|
213
|
+
function createShell() {
|
|
214
|
+
const shell = spawn('bash', [], {
|
|
215
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
216
|
+
env: process.env,
|
|
217
|
+
});
|
|
210
218
|
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
shell.on('error', (err) => {
|
|
220
|
+
console.error(`${colors.red}Shell error: ${err.message}${colors.reset}`);
|
|
221
|
+
});
|
|
213
222
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
});
|
|
223
|
+
return shell;
|
|
224
|
+
}
|
|
217
225
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Run a code block in the persistent shell.
|
|
228
|
+
* Returns a promise that resolves with { success, code, skipped }.
|
|
229
|
+
*/
|
|
230
|
+
function runInShell(shell, code) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const marker = `PRQT_${randomBytes(16).toString('hex')}`;
|
|
233
|
+
const markerPrefix = `${marker}_EXIT_`;
|
|
234
|
+
|
|
235
|
+
let stdoutBuf = '';
|
|
236
|
+
|
|
237
|
+
const onStdout = (data) => {
|
|
238
|
+
stdoutBuf += data.toString();
|
|
239
|
+
|
|
240
|
+
// Process complete lines, keeping any incomplete line in the buffer
|
|
241
|
+
let newlineIdx;
|
|
242
|
+
while ((newlineIdx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
243
|
+
const line = stdoutBuf.slice(0, newlineIdx);
|
|
244
|
+
stdoutBuf = stdoutBuf.slice(newlineIdx + 1);
|
|
245
|
+
|
|
246
|
+
const markerIdx = line.indexOf(markerPrefix);
|
|
247
|
+
if (markerIdx !== -1) {
|
|
248
|
+
// Output any content before the marker (e.g., if previous command didn't end with newline)
|
|
249
|
+
if (markerIdx > 0) {
|
|
250
|
+
process.stdout.write(line.slice(0, markerIdx) + '\n');
|
|
251
|
+
}
|
|
252
|
+
const exitCode = parseInt(line.slice(markerIdx + markerPrefix.length), 10);
|
|
253
|
+
cleanup();
|
|
254
|
+
resolve({ success: exitCode === 0, code: exitCode });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
process.stdout.write(line + '\n');
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const onStderr = (data) => {
|
|
263
|
+
process.stderr.write(data);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const onClose = () => {
|
|
267
|
+
cleanup();
|
|
268
|
+
resolve({ success: false, code: 1 });
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function cleanup() {
|
|
272
|
+
shell.stdout.off('data', onStdout);
|
|
273
|
+
shell.stderr.off('data', onStderr);
|
|
274
|
+
shell.off('close', onClose);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
shell.stdout.on('data', onStdout);
|
|
278
|
+
shell.stderr.on('data', onStderr);
|
|
279
|
+
shell.on('close', onClose);
|
|
280
|
+
|
|
281
|
+
// Write the code followed by the marker echo
|
|
282
|
+
shell.stdin.write(`${code}\necho "${markerPrefix}$?"\n`);
|
|
222
283
|
});
|
|
223
284
|
}
|
|
224
285
|
|
|
@@ -270,10 +331,16 @@ async function run(content, skipConfirm = false) {
|
|
|
270
331
|
console.log(`${colors.cyan}Found ${codeBlocks.length} code block(s) in Testing section.${colors.reset}\n`);
|
|
271
332
|
console.log(`${colors.cyan}─────────────────────────────────${colors.reset}\n`);
|
|
272
333
|
|
|
334
|
+
const shell = createShell();
|
|
335
|
+
|
|
273
336
|
let codeBlockIndex = 0;
|
|
274
337
|
let lastCodeBlockRan = false;
|
|
275
338
|
const conditionResults = [];
|
|
276
339
|
|
|
340
|
+
function closeShell() {
|
|
341
|
+
shell.stdin.end();
|
|
342
|
+
}
|
|
343
|
+
|
|
277
344
|
for (const block of blocks) {
|
|
278
345
|
if (block.type === 'text') {
|
|
279
346
|
// Echo non-code content
|
|
@@ -291,7 +358,7 @@ async function run(content, skipConfirm = false) {
|
|
|
291
358
|
continue;
|
|
292
359
|
}
|
|
293
360
|
|
|
294
|
-
const answer = await
|
|
361
|
+
const answer = await promptUser(`${colors.yellow}Was this condition met? [y/N] ${colors.reset}`);
|
|
295
362
|
const passed = answer === 'y' || answer === 'yes';
|
|
296
363
|
|
|
297
364
|
if (passed) {
|
|
@@ -305,6 +372,22 @@ async function run(content, skipConfirm = false) {
|
|
|
305
372
|
codeBlockIndex++;
|
|
306
373
|
lastCodeBlockRan = false;
|
|
307
374
|
|
|
375
|
+
// Check if this is a supported shell language
|
|
376
|
+
if (!shellLangs.has(block.lang) && block.lang !== '') {
|
|
377
|
+
console.log(`${colors.yellow}⚠ Skipping unsupported language: ${block.lang}${colors.reset}\n`);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Check if this is an export-only block
|
|
382
|
+
const exportVars = getExportOnlyVars(block.content);
|
|
383
|
+
if (exportVars) {
|
|
384
|
+
// Run silently without prompting
|
|
385
|
+
await runInShell(shell, block.content);
|
|
386
|
+
lastCodeBlockRan = true;
|
|
387
|
+
console.log(`${colors.dim} ↳ Set ${exportVars.join(', ')}${colors.reset}\n`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
308
391
|
// Show the code block
|
|
309
392
|
console.log(`${colors.cyan}┌─ [${codeBlockIndex}/${codeBlocks.length}] ${block.lang || 'code'} ─────────────────────${colors.reset}`);
|
|
310
393
|
console.log(`${colors.dim}${block.content}${colors.reset}`);
|
|
@@ -312,10 +395,11 @@ async function run(content, skipConfirm = false) {
|
|
|
312
395
|
|
|
313
396
|
// Prompt to run this block
|
|
314
397
|
if (!skipConfirm) {
|
|
315
|
-
const answer = await
|
|
398
|
+
const answer = await promptUser(`${colors.yellow}Run this block? [y/N/q] ${colors.reset}`);
|
|
316
399
|
|
|
317
400
|
if (answer === 'q' || answer === 'quit') {
|
|
318
401
|
console.log('Aborted.');
|
|
402
|
+
closeShell();
|
|
319
403
|
printConditionSummary(conditionResults);
|
|
320
404
|
return;
|
|
321
405
|
}
|
|
@@ -328,20 +412,19 @@ async function run(content, skipConfirm = false) {
|
|
|
328
412
|
|
|
329
413
|
// Execute the block
|
|
330
414
|
console.log(`${colors.cyan}├─ output ─────────────────────${colors.reset}`);
|
|
331
|
-
const result = await
|
|
415
|
+
const result = await runInShell(shell, block.content);
|
|
332
416
|
lastCodeBlockRan = true;
|
|
333
417
|
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
console.log(`${colors.cyan}└─ ${colors.red}✗ failed (exit ${result.code})${colors.reset}`);
|
|
339
|
-
}
|
|
418
|
+
if (result.success) {
|
|
419
|
+
console.log(`${colors.cyan}└─ ${colors.green}✓ success${colors.reset}`);
|
|
420
|
+
} else {
|
|
421
|
+
console.log(`${colors.cyan}└─ ${colors.red}✗ failed (exit ${result.code})${colors.reset}`);
|
|
340
422
|
}
|
|
341
423
|
console.log();
|
|
342
424
|
}
|
|
343
425
|
}
|
|
344
426
|
|
|
427
|
+
closeShell();
|
|
345
428
|
printConditionSummary(conditionResults);
|
|
346
429
|
console.log(`${colors.green}Done!${colors.reset}`);
|
|
347
430
|
}
|
|
@@ -366,11 +449,15 @@ ${colors.yellow}Examples:${colors.reset}
|
|
|
366
449
|
${colors.yellow}How it works:${colors.reset}
|
|
367
450
|
Only runs code blocks under a "Testing", "Tests", or "Test" header.
|
|
368
451
|
The section ends when another header of equal or higher level appears.
|
|
452
|
+
All blocks run in a single shell session — environment variables,
|
|
453
|
+
working directory changes, and other state persist across blocks.
|
|
369
454
|
|
|
370
455
|
${colors.yellow}Supported languages:${colors.reset}
|
|
371
|
-
bash, sh, shell
|
|
372
|
-
|
|
373
|
-
|
|
456
|
+
bash, sh, shell, zsh Executed in a persistent bash shell
|
|
457
|
+
|
|
458
|
+
${colors.yellow}Export-only blocks:${colors.reset}
|
|
459
|
+
Blocks containing only export statements run automatically without
|
|
460
|
+
prompting (e.g., setup blocks that set BASE_URL).
|
|
374
461
|
|
|
375
462
|
Requires GitHub CLI (gh): https://cli.github.com
|
|
376
463
|
`);
|