Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions examples/test-proper-json-usage.mjs
Original file line number Diff line number Diff line change
@@ -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!');
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 10 additions & 1 deletion src/$.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down
3 changes: 3 additions & 0 deletions src/shell-parser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions tests/json-escaping.test.mjs
Original file line number Diff line number Diff line change
@@ -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 ".*"$/);

Check failure on line 12 in tests/json-escaping.test.mjs

View workflow job for this annotation

GitHub Actions / test (latest)

error: expect(received).toMatch(expected)

Expected substring or pattern: /^echo ".*"$/ Received: "echo '{\"name\":\"test\",\"value\":\"with '\\''quotes'\\'' and \\\"double quotes\\\"\"}'" at <anonymous> (/home/runner/work/command-stream/command-stream/tests/json-escaping.test.mjs:12:28) at <anonymous> (/home/runner/work/command-stream/command-stream/tests/json-escaping.test.mjs:6:57)

// 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);
}
});
Loading