diff --git a/examples/test-proper-json-usage.mjs b/examples/test-proper-json-usage.mjs new file mode 100755 index 0000000..204d118 --- /dev/null +++ b/examples/test-proper-json-usage.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { $ } from '../src/$.mjs'; +import fs from 'fs'; + +// Proper usage test - demonstrates the correct way to use JSON with shell commands +console.log('🔍 Proper JSON usage test...'); + +const jsonData = { + name: "Test Project", + description: "A project with \"quotes\" and 'apostrophes'", + scripts: { + test: "echo \"Running tests\"", + build: "node build.js --env='production'" + }, + config: { + special: "Value with `backticks` and $variables" + } +}; + +const jsonString = JSON.stringify(jsonData, null, 2); +const outputFile = '/tmp/proper-json-test.json'; + +console.log('📝 Original JSON:'); +console.log(jsonString); +console.log(); + +// CORRECT USAGE: Write to file using redirection +console.log('✅ Writing JSON to file using shell redirection...'); +const cmd1 = $({ mirror: false })`echo ${jsonString} > ${outputFile}`; +console.log('Command:', cmd1.spec.command.substring(0, 80) + '...'); + +await cmd1; + +// Read back and validate +console.log('📖 Reading back from file...'); +const fileContent = await fs.promises.readFile(outputFile, 'utf-8'); +console.log('File content:'); +console.log(fileContent); +console.log(); + +console.log('🔍 Validating JSON from file...'); +try { + const parsed = JSON.parse(fileContent); + console.log('✅ SUCCESS: File contains valid JSON!'); + console.log('✅ Name:', parsed.name); + console.log('✅ Description contains quotes:', parsed.description.includes('"quotes"') && parsed.description.includes("'apostrophes'")); + console.log('✅ Special chars preserved:', parsed.config.special.includes('`backticks`') && parsed.config.special.includes('$variables')); +} catch (e) { + console.log('❌ FAILED: File JSON is invalid:', e.message); +} + +console.log(); + +// ALTERNATIVE USAGE: Just echo to stdout (what the user might expect) +console.log('✅ Echoing JSON to stdout for processing...'); +const result = await $({ capture: true, mirror: false })`echo ${jsonString}`; +const stdoutJson = result.stdout.trim(); + +console.log('🔍 Validating JSON from stdout...'); +try { + const parsed = JSON.parse(stdoutJson); + console.log('✅ SUCCESS: Stdout contains valid JSON!'); + console.log('✅ Can be piped to other commands or processed'); +} catch (e) { + console.log('❌ FAILED: Stdout JSON is invalid:', e.message); + console.log('Raw stdout:', JSON.stringify(stdoutJson)); +} + +console.log(); + +// Show the difference between old and new behavior +console.log('🔍 Comparison - the old behavior would have looked like:'); +console.log("OLD: echo '{ \"name\": \"Test Project\", \"description\": \"A project with \\\"quotes\\\" and '\\''apostrophes'\\''\",...}'"); +console.log('NEW:', cmd1.spec.command.substring(0, 120) + '...'); +console.log('✅ The new behavior properly preserves JSON structure!'); \ No newline at end of file diff --git a/package.json b/package.json index 6723c5b..6ac902d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "command-stream", - "version": "0.7.1", + "version": "0.7.2", "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime", "type": "module", "main": "src/$.mjs", diff --git a/src/$.mjs b/src/$.mjs index 46c7258..cfc096d 100755 --- a/src/$.mjs +++ b/src/$.mjs @@ -728,6 +728,7 @@ function quote(value) { if (typeof value !== 'string') value = String(value); if (value === '') return "''"; + // If the value is already properly quoted and doesn't need further escaping, // check if we can use it as-is or with simpler quoting if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { @@ -1649,6 +1650,14 @@ class ProcessRunner extends StreamEmitter { this.spec.command.includes('||') || this.spec.command.includes('(') || this.spec.command.includes(';') || + this.spec.command.includes(' > ') || + this.spec.command.includes(' >> ') || + this.spec.command.includes(' < ') || + this.spec.command.includes(' 2> ') || + this.spec.command.includes(' 2>> ') || + this.spec.command.includes(' &> ') || + /\s>\s*[^\s]/.test(this.spec.command) || // Handle >file without spaces + /\s>>\s*[^\s]/.test(this.spec.command) || // Handle >>file without spaces (this.spec.command.includes('cd ') && this.spec.command.includes('&&')); // Intelligent detection: disable shell operators for streaming patterns @@ -1700,7 +1709,7 @@ class ProcessRunner extends StreamEmitter { commandCount: parsed.commands?.length }, null, 2)}`); return await this._runPipeline(parsed.commands); - } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd) && !this.options._bypassVirtual) { + } else if (parsed.type === 'simple' && virtualCommandsEnabled && virtualCommands.has(parsed.cmd) && !this.options._bypassVirtual && !needsRealShell(this.spec.command)) { // For built-in virtual commands that have real counterparts (like sleep), // skip the virtual version when custom stdin is provided to ensure proper process handling const hasCustomStdin = this.options.stdin && diff --git a/src/shell-parser.mjs b/src/shell-parser.mjs index edbf011..97a2b0c 100644 --- a/src/shell-parser.mjs +++ b/src/shell-parser.mjs @@ -356,6 +356,9 @@ export function needsRealShell(command) { '*', // Glob patterns '?', // Glob patterns '[', // Glob patterns + '>', // Output redirection (basic) + '>>', // Append redirection + '<', // Input redirection '2>', // stderr redirection '&>', // Combined redirection '>&', // File descriptor duplication diff --git a/tests/json-escaping.test.mjs b/tests/json-escaping.test.mjs new file mode 100644 index 0000000..66cf1df --- /dev/null +++ b/tests/json-escaping.test.mjs @@ -0,0 +1,115 @@ +import { $ } from '../src/$.mjs'; +import { test, expect } from 'bun:test'; +import './test-helper.mjs'; // Automatically sets up beforeEach/afterEach cleanup +import fs from 'fs'; + +test('JSON escaping - basic JSON string interpolation', async () => { + const jsonData = { name: "test", value: "with 'quotes' and \"double quotes\"" }; + const jsonString = JSON.stringify(jsonData); + + // Test command generation + const cmd = $({ mirror: false })`echo ${jsonString}`; + expect(cmd.spec.command).toMatch(/^echo ".*"$/); + + // Test actual execution + const result = await $({ capture: true, mirror: false })`echo ${jsonString}`; + const parsed = JSON.parse(result.stdout.trim()); + + expect(parsed.name).toBe("test"); + expect(parsed.value).toBe("with 'quotes' and \"double quotes\""); +}); + +test('JSON escaping - complex JSON with nested quotes and special characters', async () => { + const jsonData = { + name: "Test Project", + description: "A project with \"quotes\" and 'apostrophes'", + scripts: { + test: "echo \"Running tests\"", + build: "node build.js --env='production'" + }, + config: { + special: "Value with `backticks` and $variables" + } + }; + + const jsonString = JSON.stringify(jsonData, null, 2); + + // Test that the JSON can be echoed and parsed back correctly + const result = await $({ capture: true, mirror: false })`echo ${jsonString}`; + const parsed = JSON.parse(result.stdout.trim()); + + expect(parsed.name).toBe("Test Project"); + expect(parsed.description).toContain('"quotes"'); + expect(parsed.description).toContain("'apostrophes'"); + expect(parsed.scripts.test).toBe('echo "Running tests"'); + expect(parsed.config.special).toContain('`backticks`'); + expect(parsed.config.special).toContain('$variables'); +}); + +test('JSON escaping - file redirection works with JSON strings', async () => { + const jsonData = { test: "value with 'quotes'" }; + const jsonString = JSON.stringify(jsonData); + const outputFile = '/tmp/json-test-' + Math.random().toString(36).substring(7) + '.json'; + + try { + // Write JSON to file using redirection + await $`echo ${jsonString} > ${outputFile}`; + + // Read back and verify + const fileContent = await fs.promises.readFile(outputFile, 'utf-8'); + const parsed = JSON.parse(fileContent.trim()); + + expect(parsed.test).toBe("value with 'quotes'"); + } finally { + // Cleanup + try { + await fs.promises.unlink(outputFile); + } catch (e) { + // Ignore cleanup errors + } + } +}); + +test('JSON escaping - JSON arrays are handled correctly', async () => { + const jsonArray = [ + { name: "Item 1", value: "test 'with' quotes" }, + { name: "Item 2", value: 'another "test" case' } + ]; + + const jsonString = JSON.stringify(jsonArray); + + const result = await $({ capture: true, mirror: false })`echo ${jsonString}`; + const parsed = JSON.parse(result.stdout.trim()); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0].value).toBe("test 'with' quotes"); + expect(parsed[1].value).toBe('another "test" case'); +}); + +test('JSON escaping - non-JSON strings with braces use old logic', async () => { + const nonJsonString = "{ this is not json but has braces }"; + + const cmd = $({ mirror: false })`echo ${nonJsonString}`; + + // Should be quoted with single quotes since it's not JSON-like + expect(cmd.spec.command).toBe("echo '{ this is not json but has braces }'"); +}); + +test('JSON escaping - empty and edge case objects', async () => { + const testCases = [ + {}, + { "": "" }, + { key: null }, + { key: true }, + { key: 123 } + ]; + + for (const testCase of testCases) { + const jsonString = JSON.stringify(testCase); + const result = await $({ capture: true, mirror: false })`echo ${jsonString}`; + const parsed = JSON.parse(result.stdout.trim()); + + expect(parsed).toEqual(testCase); + } +}); \ No newline at end of file