prquicktest 1.1.0 → 2.0.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 +47 -24
- package/bin/prquicktest.mjs +126 -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,97 @@ 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
|
+
if (line.startsWith(markerPrefix)) {
|
|
247
|
+
const exitCode = parseInt(line.slice(markerPrefix.length), 10);
|
|
248
|
+
cleanup();
|
|
249
|
+
resolve({ success: exitCode === 0, code: exitCode });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
process.stdout.write(line + '\n');
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onStderr = (data) => {
|
|
258
|
+
process.stderr.write(data);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const onClose = () => {
|
|
262
|
+
cleanup();
|
|
263
|
+
resolve({ success: false, code: 1 });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
function cleanup() {
|
|
267
|
+
shell.stdout.off('data', onStdout);
|
|
268
|
+
shell.stderr.off('data', onStderr);
|
|
269
|
+
shell.off('close', onClose);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
shell.stdout.on('data', onStdout);
|
|
273
|
+
shell.stderr.on('data', onStderr);
|
|
274
|
+
shell.on('close', onClose);
|
|
275
|
+
|
|
276
|
+
// Write the code followed by the marker echo
|
|
277
|
+
shell.stdin.write(`${code}\necho "${markerPrefix}$?"\n`);
|
|
222
278
|
});
|
|
223
279
|
}
|
|
224
280
|
|
|
@@ -270,10 +326,16 @@ async function run(content, skipConfirm = false) {
|
|
|
270
326
|
console.log(`${colors.cyan}Found ${codeBlocks.length} code block(s) in Testing section.${colors.reset}\n`);
|
|
271
327
|
console.log(`${colors.cyan}─────────────────────────────────${colors.reset}\n`);
|
|
272
328
|
|
|
329
|
+
const shell = createShell();
|
|
330
|
+
|
|
273
331
|
let codeBlockIndex = 0;
|
|
274
332
|
let lastCodeBlockRan = false;
|
|
275
333
|
const conditionResults = [];
|
|
276
334
|
|
|
335
|
+
function closeShell() {
|
|
336
|
+
shell.stdin.end();
|
|
337
|
+
}
|
|
338
|
+
|
|
277
339
|
for (const block of blocks) {
|
|
278
340
|
if (block.type === 'text') {
|
|
279
341
|
// Echo non-code content
|
|
@@ -291,7 +353,7 @@ async function run(content, skipConfirm = false) {
|
|
|
291
353
|
continue;
|
|
292
354
|
}
|
|
293
355
|
|
|
294
|
-
const answer = await
|
|
356
|
+
const answer = await promptUser(`${colors.yellow}Was this condition met? [y/N] ${colors.reset}`);
|
|
295
357
|
const passed = answer === 'y' || answer === 'yes';
|
|
296
358
|
|
|
297
359
|
if (passed) {
|
|
@@ -305,6 +367,22 @@ async function run(content, skipConfirm = false) {
|
|
|
305
367
|
codeBlockIndex++;
|
|
306
368
|
lastCodeBlockRan = false;
|
|
307
369
|
|
|
370
|
+
// Check if this is a supported shell language
|
|
371
|
+
if (!shellLangs.has(block.lang) && block.lang !== '') {
|
|
372
|
+
console.log(`${colors.yellow}⚠ Skipping unsupported language: ${block.lang}${colors.reset}\n`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check if this is an export-only block
|
|
377
|
+
const exportVars = getExportOnlyVars(block.content);
|
|
378
|
+
if (exportVars) {
|
|
379
|
+
// Run silently without prompting
|
|
380
|
+
await runInShell(shell, block.content);
|
|
381
|
+
lastCodeBlockRan = true;
|
|
382
|
+
console.log(`${colors.dim} ↳ Set ${exportVars.join(', ')}${colors.reset}\n`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
308
386
|
// Show the code block
|
|
309
387
|
console.log(`${colors.cyan}┌─ [${codeBlockIndex}/${codeBlocks.length}] ${block.lang || 'code'} ─────────────────────${colors.reset}`);
|
|
310
388
|
console.log(`${colors.dim}${block.content}${colors.reset}`);
|
|
@@ -312,10 +390,11 @@ async function run(content, skipConfirm = false) {
|
|
|
312
390
|
|
|
313
391
|
// Prompt to run this block
|
|
314
392
|
if (!skipConfirm) {
|
|
315
|
-
const answer = await
|
|
393
|
+
const answer = await promptUser(`${colors.yellow}Run this block? [y/N/q] ${colors.reset}`);
|
|
316
394
|
|
|
317
395
|
if (answer === 'q' || answer === 'quit') {
|
|
318
396
|
console.log('Aborted.');
|
|
397
|
+
closeShell();
|
|
319
398
|
printConditionSummary(conditionResults);
|
|
320
399
|
return;
|
|
321
400
|
}
|
|
@@ -328,20 +407,19 @@ async function run(content, skipConfirm = false) {
|
|
|
328
407
|
|
|
329
408
|
// Execute the block
|
|
330
409
|
console.log(`${colors.cyan}├─ output ─────────────────────${colors.reset}`);
|
|
331
|
-
const result = await
|
|
410
|
+
const result = await runInShell(shell, block.content);
|
|
332
411
|
lastCodeBlockRan = true;
|
|
333
412
|
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
console.log(`${colors.cyan}└─ ${colors.red}✗ failed (exit ${result.code})${colors.reset}`);
|
|
339
|
-
}
|
|
413
|
+
if (result.success) {
|
|
414
|
+
console.log(`${colors.cyan}└─ ${colors.green}✓ success${colors.reset}`);
|
|
415
|
+
} else {
|
|
416
|
+
console.log(`${colors.cyan}└─ ${colors.red}✗ failed (exit ${result.code})${colors.reset}`);
|
|
340
417
|
}
|
|
341
418
|
console.log();
|
|
342
419
|
}
|
|
343
420
|
}
|
|
344
421
|
|
|
422
|
+
closeShell();
|
|
345
423
|
printConditionSummary(conditionResults);
|
|
346
424
|
console.log(`${colors.green}Done!${colors.reset}`);
|
|
347
425
|
}
|
|
@@ -366,11 +444,15 @@ ${colors.yellow}Examples:${colors.reset}
|
|
|
366
444
|
${colors.yellow}How it works:${colors.reset}
|
|
367
445
|
Only runs code blocks under a "Testing", "Tests", or "Test" header.
|
|
368
446
|
The section ends when another header of equal or higher level appears.
|
|
447
|
+
All blocks run in a single shell session — environment variables,
|
|
448
|
+
working directory changes, and other state persist across blocks.
|
|
369
449
|
|
|
370
450
|
${colors.yellow}Supported languages:${colors.reset}
|
|
371
|
-
bash, sh, shell
|
|
372
|
-
|
|
373
|
-
|
|
451
|
+
bash, sh, shell, zsh Executed in a persistent bash shell
|
|
452
|
+
|
|
453
|
+
${colors.yellow}Export-only blocks:${colors.reset}
|
|
454
|
+
Blocks containing only export statements run automatically without
|
|
455
|
+
prompting (e.g., setup blocks that set BASE_URL).
|
|
374
456
|
|
|
375
457
|
Requires GitHub CLI (gh): https://cli.github.com
|
|
376
458
|
`);
|