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 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 | Executor |
76
- |----------|---------|----------|
77
- | Bash | `bash`, `sh`, `shell` | `bash -c` |
78
- | JavaScript | `javascript`, `js`, `node` | `node -e` |
79
- | Python | `python`, `py`, `python3` | `python3 -c` |
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
 
@@ -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 langConfig = {
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 prompt(question) {
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
- function runCode(lang, code) {
197
- return new Promise((resolve) => {
198
- const config = langConfig[lang];
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
- if (!config) {
201
- console.log(`${colors.yellow}⚠ Skipping: ${lang}${colors.reset}`);
202
- resolve({ success: true, skipped: true });
203
- return;
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
- const proc = spawn(config.cmd, [...config.args, code], {
207
- stdio: ['inherit', 'pipe', 'pipe'],
208
- shell: false,
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
- proc.stdout.on('data', (data) => process.stdout.write(data));
212
- proc.stderr.on('data', (data) => process.stderr.write(data));
219
+ shell.on('error', (err) => {
220
+ console.error(`${colors.red}Shell error: ${err.message}${colors.reset}`);
221
+ });
213
222
 
214
- proc.on('close', (exitCode) => {
215
- resolve({ success: exitCode === 0, code: exitCode });
216
- });
223
+ return shell;
224
+ }
217
225
 
218
- proc.on('error', (err) => {
219
- console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
220
- resolve({ success: false, error: err });
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 prompt(`${colors.yellow}Was this condition met? [y/N] ${colors.reset}`);
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 prompt(`${colors.yellow}Run this block? [y/N/q] ${colors.reset}`);
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 runCode(block.lang, block.content);
410
+ const result = await runInShell(shell, block.content);
332
411
  lastCodeBlockRan = true;
333
412
 
334
- if (!result.skipped) {
335
- if (result.success) {
336
- console.log(`${colors.cyan}└─ ${colors.green}✓ success${colors.reset}`);
337
- } else {
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 Executed with bash -c
372
- javascript, js, node Executed with node -e
373
- python, py, python3 Executed with python3 -c
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
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prquicktest",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Run code blocks from GitHub PR descriptions",
5
5
  "type": "module",
6
6
  "scripts": {