Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,16 @@ jobs:
process.exit(1);
});
"
node -e "
const commandStream = require('./js/src/\$.cjs');
if (typeof commandStream !== 'function') {
console.error('CommonJS entry did not export a callable \$');
process.exit(1);
}
console.log('CommonJS entry loads successfully in Node.js ${{ matrix.node-version }}');
"
node --test js/tests/node-terminal-artifacts.mjs
node --test js/tests/node-commonjs-entry.mjs

release:
name: Release JavaScript package
Expand Down
5 changes: 5 additions & 0 deletions js/.changeset/commonjs-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'command-stream': minor
---

Add a CommonJS entry point (`src/$.cjs`) published under the `require` export condition, so `require('command-stream')` returns the callable `$` with every named export attached and CommonJS hosts can use the synchronous `ProcessRunner.sync()` API without `await import()`. Both entry points load one shared module instance, so virtual commands, shell settings, and cleanup state stay in sync regardless of how the package was loaded.
39 changes: 39 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,34 @@ const result = await runner;
The main `command-stream` entry point remains the supported import for `$` and
terminal capture features.

## Module Formats (ESM and CommonJS)

The package ships both entry points and they resolve automatically:

| Host | Resolved entry | How to load |
| -------- | -------------- | ------------------------------------- |
| ESM | `src/$.mjs` | `import { $ } from 'command-stream'` |
| CommonJS | `src/$.cjs` | `const $ = require('command-stream')` |

```javascript
// CommonJS: the exported value is the $ tagged template itself,
// with every named export attached to it.
const $ = require('command-stream');
const { sh, run, ProcessRunner, shell } = require('command-stream');

const result = $({ mirror: false })`echo hello`.sync();
console.log(result.stdout.trim()); // "hello"
```

Both entry points load the same module instance, so virtual command
registrations, shell settings, and cleanup state are shared no matter how the
package was loaded.

`require('command-stream')` needs a runtime with `require(esm)` support:
Node.js >= 20.19.0, Node.js >= 22.12.0, or Bun. On older Node.js versions the
package throws an explicit error asking you to upgrade or to use
`await import('command-stream')` instead.

## Smart Quoting & Security

Command-stream provides intelligent auto-quoting to protect against shell injection while avoiding unnecessary quotes for safe strings:
Expand Down Expand Up @@ -418,6 +446,17 @@ console.log(result.stdout); // "hello\n"
$`echo "world"`.on('end', (result) => console.log('Done:', result)).sync();
```

`.sync()` is also reachable from CommonJS without any `await`, which makes it
usable at synchronous launch-time boundaries such as availability probes:

```javascript
const $ = require('command-stream');

function isGitAvailable() {
return $({ mirror: false })`git --version`.sync().code === 0;
}
```

### TUI Capture

`captureTerminal()` runs a command in a real pseudoterminal and uses xterm's
Expand Down
27 changes: 27 additions & 0 deletions js/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ export default [
'max-lines': ['error', 1500], // Maximum lines per file - enforced for all source files
},
},
{
// The CommonJS entry point ($.cjs) is parsed as a script, not a module
files: ['**/*.cjs'],
plugins: {
prettier: prettierPlugin,
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'writable',
exports: 'writable',
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
rules: {
'prettier/prettier': 'error',
curly: ['error', 'all'],
'no-var': 'error',
'prefer-const': 'error',
},
},
{
// Test files have different requirements
files: [
Expand Down
47 changes: 47 additions & 0 deletions js/examples/commonjs-launch-probe.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Synchronous launch-time probes from a CommonJS host (issue #189).
//
// A CommonJS process cannot await at module scope, so `await import(...)` is not
// an option for a probe that has to answer before the host finishes booting.
// `require('command-stream')` returns the `$` tagged template synchronously, so
// `.sync()` can be used directly in that boundary.
//
// Run with: node js/examples/commonjs-launch-probe.cjs

'use strict';

// Installed consumers write require('command-stream'); this example is run from
// inside the repository, so it points at the CommonJS entry point directly.
const $ = require('../src/$.cjs');

// `mirror: false` keeps probe output out of the host's own stdout.
const probe = $({ mirror: false });

/**
* Check whether a command exists and report the version it prints.
*
* @param {string} tool Executable name, e.g. 'git'.
* @returns {{ available: boolean, version: string | null }} Probe outcome.
*/
function probeTool(tool) {
const result = probe`${tool} --version`.sync();
return {
available: result.code === 0,
version: result.code === 0 ? result.stdout.trim().split('\n')[0] : null,
};
}

const tools = ['git', 'node', 'definitely-not-installed-tool'];

console.log('Launch-time probes (fully synchronous, no await):');
for (const tool of tools) {
const { available, version } = probeTool(tool);
console.log(` ${tool}: ${available ? version : 'not available'}`);
}

// The named exports are attached to the same value, so both shapes work.
const { sh, ProcessRunner } = require('../src/$.cjs');
console.log('sh is a function:', typeof sh === 'function');
console.log(
'probe returns a ProcessRunner:',
probe`true` instanceof ProcessRunner
);
46 changes: 46 additions & 0 deletions js/experiments/repro-189-commonjs-require.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Reproduction harness for issue #189.
//
// Before the fix, package.json exported only './src/$.mjs', so from a CommonJS
// host `require('command-stream')` failed with ERR_REQUIRE_ESM on runtimes
// without require(esm), and returned a namespace object (not a callable `$`)
// on the runtimes that do support it.
//
// This script installs the package into a throwaway sandbox and reports what a
// CommonJS host actually observes on the current runtime.
//
// Run with: node js/experiments/repro-189-commonjs-require.mjs

import {
createCommonJsSandbox,
removeCommonJsSandbox,
runSandboxScript,
supportsRequireEsm,
} from '../tests/commonjs-sandbox.mjs';

const sandbox = createCommonJsSandbox();

try {
console.log(`node ${process.versions.node}`);
console.log(
`require(esm) supported: ${supportsRequireEsm(process.versions.node)}`
);

const result = runSandboxScript(sandbox, 'repro.cjs', [
"const loaded = require('command-stream');",
"console.log('typeof require result:', typeof loaded);",
"console.log('callable as a tagged template:', typeof loaded === 'function');",
'const probe = loaded({ mirror: false })`echo sync-probe`.sync();',
"console.log('sync probe:', JSON.stringify(probe.stdout), 'code:', probe.code);",
]);

console.log(`exit status: ${result.status}`);
if (result.stdout) {
console.log(result.stdout.trimEnd());
}
if (result.stderr) {
console.log('stderr:');
console.log(result.stderr.trimEnd());
}
} finally {
removeCommonJsSandbox(sandbox);
}
12 changes: 9 additions & 3 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
"version": "0.18.2",
"description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime",
"type": "module",
"main": "src/$.mjs",
"main": "./src/$.cjs",
"module": "./src/$.mjs",
"exports": {
".": "./src/$.mjs",
"./process-runner": "./src/process-runner.mjs"
".": {
"import": "./src/$.mjs",
"require": "./src/$.cjs",
"default": "./src/$.mjs"
},
"./process-runner": "./src/process-runner.mjs",
"./package.json": "./package.json"
},
"repository": {
"type": "git",
Expand Down
101 changes: 101 additions & 0 deletions js/src/$.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// command-stream - CommonJS entry point
//
// Issue #189: the package shipped only the ESM entry point, so a CommonJS host
// could reach the library exclusively through `await import('command-stream')`.
// That is asynchronous by definition and therefore unusable at a synchronous
// launch-time boundary, even though `ProcessRunner.sync()` itself is synchronous.
//
// This wrapper loads the single ESM module graph through `require(esm)`
// (Node.js >= 20.19.0 / >= 22.12.0, and Bun). Because the ESM graph is loaded by
// the same module registry that `import` uses, `require('command-stream')` and
// `import('command-stream')` share one instance: virtual command registrations,
// shell settings, and process cleanup state stay in sync. There is no second,
// bundled copy of the library and therefore no dual-package hazard.
//
// The exported value is the `$` tagged template function itself, with every
// named export attached to it, so both CommonJS shapes work:
//
// const $ = require('command-stream');
// const { $, sh, ProcessRunner } = require('command-stream');

'use strict';

const ESM_ENTRY = './$.mjs';

/**
* Load the ESM entry point synchronously.
*
* Runtimes without `require(esm)` support throw ERR_REQUIRE_ESM, which is
* opaque for consumers of this package; replace it with an actionable message.
*
* @returns {object} The `$.mjs` module namespace object.
*/
function loadEsmNamespace() {
try {
return require(ESM_ENTRY);
} catch (error) {
if (error && error.code === 'ERR_REQUIRE_ESM') {
const runtime =
typeof process !== 'undefined' && process.version
? ` (running ${process.version})`
: '';
throw new Error(
'command-stream: require() of this package needs a runtime with ' +
`require(esm) support - Node.js >= 20.19.0 or >= 22.12.0${runtime}. ` +
"Upgrade Node.js, or load the package with `await import('command-stream')`.",
{ cause: error }
);
}
throw error;
}
}

const namespace = loadEsmNamespace();

/**
* CommonJS view of the default export. It forwards to the ESM `$` instead of
* being the very same function object, so attaching the named exports below
* does not mutate the value seen by ESM consumers.
*
* @param {...*} args Tagged template arguments, or an options object.
* @returns {*} A ProcessRunner, or an options-bound tagged template function.
*/
function $(...args) {
return namespace.$(...args);
}

// Skipped keys: `$`/`default` are re-pointed at the wrapper below, and
// `__esModule` is a marker some runtimes add to require(esm) namespaces.
const RE_EXPORT_SKIP = new Set(['$', 'default', '__esModule']);

for (const name of Object.keys(namespace)) {
if (RE_EXPORT_SKIP.has(name)) {
continue;
}
Object.defineProperty($, name, {
value: namespace[name],
enumerable: true,
writable: true,
configurable: true,
});
}

Object.defineProperty($, '$', {
value: $,
enumerable: true,
writable: true,
configurable: true,
});

Object.defineProperty($, 'default', {
value: $,
enumerable: true,
writable: true,
configurable: true,
});

// Interop marker for transpiled `import $ from 'command-stream'` in CommonJS
// output; non-enumerable to match the shape emitted by TypeScript and Babel.
Object.defineProperty($, '__esModule', { value: true });

module.exports = $;
Loading