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 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,102 @@ 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
+ 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 prompt(`${colors.yellow}Was this condition met? [y/N] ${colors.reset}`);
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 prompt(`${colors.yellow}Run this block? [y/N/q] ${colors.reset}`);
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 runCode(block.lang, block.content);
415
+ const result = await runInShell(shell, block.content);
332
416
  lastCodeBlockRan = true;
333
417
 
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
- }
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 Executed with bash -c
372
- javascript, js, node Executed with node -e
373
- python, py, python3 Executed with python3 -c
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
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prquicktest",
3
- "version": "1.1.0",
3
+ "version": "2.0.1",
4
4
  "description": "Run code blocks from GitHub PR descriptions",
5
5
  "type": "module",
6
6
  "scripts": {