prquicktest 1.0.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);
@@ -86,6 +77,18 @@ function getHeaderLevel(line) {
86
77
  return match ? match[1].length : null;
87
78
  }
88
79
 
80
+ /**
81
+ * Check if a line starts a condition (case-insensitive)
82
+ * Matches: "condition:", "check:", "check for:"
83
+ */
84
+ function parseCondition(line) {
85
+ const match = line.match(/^(condition|check\s*for|check)\s*:\s*(.+)/i);
86
+ if (match) {
87
+ return match[2].trim();
88
+ }
89
+ return null;
90
+ }
91
+
89
92
  function parseMarkdown(content) {
90
93
  const blocks = [];
91
94
  const lines = content.split('\n');
@@ -138,10 +141,18 @@ function parseMarkdown(content) {
138
141
  } else {
139
142
  // Skip non-code content outside testing sections
140
143
  if (inTestingSection) {
144
+ // Check if this line is a condition
145
+ const condition = parseCondition(line);
146
+ if (condition) {
147
+ blocks.push({ type: 'condition', content: condition });
148
+ i++;
149
+ continue;
150
+ }
151
+
141
152
  const textLines = [line];
142
153
  i++;
143
154
 
144
- while (i < lines.length && !lines[i].match(/^```/) && !lines[i].match(/^#{1,6}\s+/)) {
155
+ while (i < lines.length && !lines[i].match(/^```/) && !lines[i].match(/^#{1,6}\s+/) && !parseCondition(lines[i])) {
145
156
  textLines.push(lines[i]);
146
157
  i++;
147
158
  }
@@ -159,7 +170,7 @@ function parseMarkdown(content) {
159
170
  return blocks;
160
171
  }
161
172
 
162
- function prompt(question) {
173
+ function promptUser(question) {
163
174
  const rl = createInterface({
164
175
  input: process.stdin,
165
176
  output: process.stdout,
@@ -173,35 +184,129 @@ function prompt(question) {
173
184
  });
174
185
  }
175
186
 
176
- function runCode(lang, code) {
177
- return new Promise((resolve) => {
178
- 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 = [];
179
194
 
180
- if (!config) {
181
- console.log(`${colors.yellow}⚠ Skipping: ${lang}${colors.reset}`);
182
- resolve({ success: true, skipped: true });
183
- return;
195
+ for (const line of lines) {
196
+ const trimmed = line.trim();
197
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
198
+
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;
184
204
  }
205
+ }
185
206
 
186
- const proc = spawn(config.cmd, [...config.args, code], {
187
- stdio: ['inherit', 'pipe', 'pipe'],
188
- shell: false,
189
- });
207
+ return varNames.length > 0 ? varNames : null;
208
+ }
190
209
 
191
- proc.stdout.on('data', (data) => process.stdout.write(data));
192
- proc.stderr.on('data', (data) => process.stderr.write(data));
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
+ });
193
218
 
194
- proc.on('close', (exitCode) => {
195
- resolve({ success: exitCode === 0, code: exitCode });
196
- });
219
+ shell.on('error', (err) => {
220
+ console.error(`${colors.red}Shell error: ${err.message}${colors.reset}`);
221
+ });
197
222
 
198
- proc.on('error', (err) => {
199
- console.error(`${colors.red}Error: ${err.message}${colors.reset}`);
200
- resolve({ success: false, error: err });
201
- });
223
+ return shell;
224
+ }
225
+
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`);
202
278
  });
203
279
  }
204
280
 
281
+ function printConditionSummary(conditionResults) {
282
+ if (conditionResults.length === 0) return;
283
+
284
+ console.log(`${colors.cyan}═══════════════════════════════════${colors.reset}`);
285
+ console.log(`${colors.cyan} Condition Summary${colors.reset}`);
286
+ console.log(`${colors.cyan}═══════════════════════════════════${colors.reset}\n`);
287
+
288
+ const passed = conditionResults.filter(c => c.passed).length;
289
+ const failed = conditionResults.filter(c => c.passed === false).length;
290
+ const skipped = conditionResults.filter(c => c.passed === null).length;
291
+
292
+ for (const result of conditionResults) {
293
+ let status;
294
+ if (result.passed === true) {
295
+ status = `${colors.green}✓ PASS${colors.reset}`;
296
+ } else if (result.passed === false) {
297
+ status = `${colors.red}✗ FAIL${colors.reset}`;
298
+ } else {
299
+ status = `${colors.yellow}○ SKIP${colors.reset}`;
300
+ }
301
+ console.log(` ${status} ${result.condition}`);
302
+ }
303
+
304
+ console.log();
305
+ console.log(`${colors.cyan}───────────────────────────────────${colors.reset}`);
306
+ console.log(` ${colors.green}Passed: ${passed}${colors.reset} ${colors.red}Failed: ${failed}${colors.reset} ${colors.yellow}Skipped: ${skipped}${colors.reset}`);
307
+ console.log();
308
+ }
309
+
205
310
  async function run(content, skipConfirm = false) {
206
311
  const blocks = parseMarkdown(content);
207
312
 
@@ -221,15 +326,62 @@ async function run(content, skipConfirm = false) {
221
326
  console.log(`${colors.cyan}Found ${codeBlocks.length} code block(s) in Testing section.${colors.reset}\n`);
222
327
  console.log(`${colors.cyan}─────────────────────────────────${colors.reset}\n`);
223
328
 
329
+ const shell = createShell();
330
+
224
331
  let codeBlockIndex = 0;
332
+ let lastCodeBlockRan = false;
333
+ const conditionResults = [];
334
+
335
+ function closeShell() {
336
+ shell.stdin.end();
337
+ }
225
338
 
226
339
  for (const block of blocks) {
227
340
  if (block.type === 'text') {
228
341
  // Echo non-code content
229
342
  console.log(block.content);
230
343
  console.log();
344
+ } else if (block.type === 'condition') {
345
+ // Show the condition and ask if it passed
346
+ console.log(`${colors.yellow}┌─ Condition ─────────────────────${colors.reset}`);
347
+ console.log(`${colors.yellow}│${colors.reset} ${block.content}`);
348
+ console.log(`${colors.yellow}└─────────────────────────────────${colors.reset}`);
349
+
350
+ if (!lastCodeBlockRan) {
351
+ console.log(`${colors.dim}(Code block was skipped, marking condition as skipped)${colors.reset}\n`);
352
+ conditionResults.push({ condition: block.content, passed: null });
353
+ continue;
354
+ }
355
+
356
+ const answer = await promptUser(`${colors.yellow}Was this condition met? [y/N] ${colors.reset}`);
357
+ const passed = answer === 'y' || answer === 'yes';
358
+
359
+ if (passed) {
360
+ console.log(`${colors.green}✓ Condition passed${colors.reset}\n`);
361
+ } else {
362
+ console.log(`${colors.red}✗ Condition failed${colors.reset}\n`);
363
+ }
364
+
365
+ conditionResults.push({ condition: block.content, passed });
231
366
  } else if (block.type === 'code') {
232
367
  codeBlockIndex++;
368
+ lastCodeBlockRan = false;
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
+ }
233
385
 
234
386
  // Show the code block
235
387
  console.log(`${colors.cyan}┌─ [${codeBlockIndex}/${codeBlocks.length}] ${block.lang || 'code'} ─────────────────────${colors.reset}`);
@@ -238,10 +390,12 @@ async function run(content, skipConfirm = false) {
238
390
 
239
391
  // Prompt to run this block
240
392
  if (!skipConfirm) {
241
- 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}`);
242
394
 
243
395
  if (answer === 'q' || answer === 'quit') {
244
396
  console.log('Aborted.');
397
+ closeShell();
398
+ printConditionSummary(conditionResults);
245
399
  return;
246
400
  }
247
401
 
@@ -253,19 +407,20 @@ async function run(content, skipConfirm = false) {
253
407
 
254
408
  // Execute the block
255
409
  console.log(`${colors.cyan}├─ output ─────────────────────${colors.reset}`);
256
- const result = await runCode(block.lang, block.content);
410
+ const result = await runInShell(shell, block.content);
411
+ lastCodeBlockRan = true;
257
412
 
258
- if (!result.skipped) {
259
- if (result.success) {
260
- console.log(`${colors.cyan}└─ ${colors.green}✓ success${colors.reset}`);
261
- } else {
262
- console.log(`${colors.cyan}└─ ${colors.red}✗ failed (exit ${result.code})${colors.reset}`);
263
- }
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}`);
264
417
  }
265
418
  console.log();
266
419
  }
267
420
  }
268
421
 
422
+ closeShell();
423
+ printConditionSummary(conditionResults);
269
424
  console.log(`${colors.green}Done!${colors.reset}`);
270
425
  }
271
426
 
@@ -289,11 +444,15 @@ ${colors.yellow}Examples:${colors.reset}
289
444
  ${colors.yellow}How it works:${colors.reset}
290
445
  Only runs code blocks under a "Testing", "Tests", or "Test" header.
291
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.
292
449
 
293
450
  ${colors.yellow}Supported languages:${colors.reset}
294
- bash, sh, shell Executed with bash -c
295
- javascript, js, node Executed with node -e
296
- 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).
297
456
 
298
457
  Requires GitHub CLI (gh): https://cli.github.com
299
458
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prquicktest",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Run code blocks from GitHub PR descriptions",
5
5
  "type": "module",
6
6
  "scripts": {