From 8f154ec0aeed3bbb57576e16519025662ea2eb66 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 11 Aug 2026 04:03:42 +0000 Subject: [PATCH 1/2] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/command-stream/issues/189 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 3eba852..de9673d 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1 +1,2 @@ -# .gitkeep file auto-generated at 2026-08-07T19:41:37.524Z for PR creation at branch issue-187-3d458fd12c95 for issue https://github.com/link-foundation/command-stream/issues/187 \ No newline at end of file +# .gitkeep file auto-generated at 2026-08-07T19:41:37.524Z for PR creation at branch issue-187-3d458fd12c95 for issue https://github.com/link-foundation/command-stream/issues/187 +# Updated: 2026-08-11T04:03:42.594Z \ No newline at end of file From 2236e6586ee78a6ea516a317c8335a61d6a39749 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 11 Aug 2026 04:22:11 +0000 Subject: [PATCH 2/2] feat(js): add a CommonJS entry point for the synchronous API Issue #189: the package exported only ./src/$.mjs, so a CommonJS host could reach the library only through `await import('command-stream')`. That is asynchronous by definition, which made the synchronous ProcessRunner.sync() API unusable at a synchronous launch-time probe boundary. On runtimes without require(esm) the require() call failed with ERR_REQUIRE_ESM; on newer Node.js it resolved to a module namespace object that is not callable as `$`. src/$.cjs is now published under the "require" export condition. It loads the same ESM module graph via require(esm), so require() and import() share one instance (no dual-package hazard) and exports the `$` tagged template with every named export attached to it. Runtimes without require(esm) support get an actionable error instead of ERR_REQUIRE_ESM. - src/$.cjs: CommonJS entry point - package.json: main/module/exports conditions for require and import - tests/commonjs-entry.test.mjs: Bun suite (shape, sync(), shared instance) - tests/node-commonjs-entry.mjs: node --test suite for the CI Node matrix - tests/commonjs-sandbox.mjs: shared bare-specifier sandbox helper - experiments/repro-189-commonjs-require.mjs: issue reproduction harness - examples/commonjs-launch-probe.cjs: synchronous launch-time probe example - eslint.config.js: lint .cjs files as CommonJS scripts - js.yml: exercise the CommonJS entry on Node.js 20, 22 and 24 - README.md: document both module formats --- .github/workflows/js.yml | 9 + js/.changeset/commonjs-entry-point.md | 5 + js/README.md | 39 ++++ js/eslint.config.js | 27 +++ js/examples/commonjs-launch-probe.cjs | 47 ++++ js/experiments/repro-189-commonjs-require.mjs | 46 ++++ js/package.json | 10 +- js/src/$.cjs | 101 +++++++++ js/tests/commonjs-entry.test.mjs | 202 ++++++++++++++++++ js/tests/commonjs-sandbox.mjs | 112 ++++++++++ js/tests/node-commonjs-entry.mjs | 35 +++ 11 files changed, 631 insertions(+), 2 deletions(-) create mode 100644 js/.changeset/commonjs-entry-point.md create mode 100644 js/examples/commonjs-launch-probe.cjs create mode 100644 js/experiments/repro-189-commonjs-require.mjs create mode 100644 js/src/$.cjs create mode 100644 js/tests/commonjs-entry.test.mjs create mode 100644 js/tests/commonjs-sandbox.mjs create mode 100644 js/tests/node-commonjs-entry.mjs diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index a129ad1..8f8260f 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -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 diff --git a/js/.changeset/commonjs-entry-point.md b/js/.changeset/commonjs-entry-point.md new file mode 100644 index 0000000..54555ee --- /dev/null +++ b/js/.changeset/commonjs-entry-point.md @@ -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. diff --git a/js/README.md b/js/README.md index b1aeee5..374010f 100644 --- a/js/README.md +++ b/js/README.md @@ -156,6 +156,34 @@ npm install command-stream bun add command-stream ``` +## 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: @@ -400,6 +428,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 diff --git a/js/eslint.config.js b/js/eslint.config.js index 5d9ef79..c878573 100644 --- a/js/eslint.config.js +++ b/js/eslint.config.js @@ -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: [ diff --git a/js/examples/commonjs-launch-probe.cjs b/js/examples/commonjs-launch-probe.cjs new file mode 100644 index 0000000..584f8e1 --- /dev/null +++ b/js/examples/commonjs-launch-probe.cjs @@ -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 +); diff --git a/js/experiments/repro-189-commonjs-require.mjs b/js/experiments/repro-189-commonjs-require.mjs new file mode 100644 index 0000000..05008c0 --- /dev/null +++ b/js/experiments/repro-189-commonjs-require.mjs @@ -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); +} diff --git a/js/package.json b/js/package.json index fc5943f..41086e0 100644 --- a/js/package.json +++ b/js/package.json @@ -3,9 +3,15 @@ "version": "0.18.0", "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" + ".": { + "import": "./src/$.mjs", + "require": "./src/$.cjs", + "default": "./src/$.mjs" + }, + "./package.json": "./package.json" }, "repository": { "type": "git", diff --git a/js/src/$.cjs b/js/src/$.cjs new file mode 100644 index 0000000..3a54459 --- /dev/null +++ b/js/src/$.cjs @@ -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 = $; diff --git a/js/tests/commonjs-entry.test.mjs b/js/tests/commonjs-entry.test.mjs new file mode 100644 index 0000000..18c2a10 --- /dev/null +++ b/js/tests/commonjs-entry.test.mjs @@ -0,0 +1,202 @@ +// Regression tests for the CommonJS entry point (issue #189). +// +// The package used to export only `./src/$.mjs`, so a CommonJS host could only +// reach the library through `await import('command-stream')`. That is +// asynchronous, which made the synchronous `ProcessRunner.sync()` API unusable +// at a synchronous launch-time probe boundary. +// +// `./src/$.cjs` is now published under the "require" export condition. These +// tests pin the resolved shape (callable `$` with named exports attached), that +// `.sync()` really runs synchronously from `require()`, and that require/import +// keep sharing a single module instance (no dual-package hazard). + +import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { + PACKAGE_ROOT, + createCommonJsSandbox, + detectNodeVersion, + removeCommonJsSandbox, + runSandboxScript, + supportsRequireEsm, +} from './commonjs-sandbox.mjs'; + +const PACKAGE_JSON = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8') +); + +let sandbox = null; +let nodeVersion = null; + +/** Whether the node executable on PATH can run the bare-specifier probes. */ +function canProbeWithNode() { + if (!nodeVersion) { + console.log('Skipping: node executable is not available'); + return false; + } + if (!supportsRequireEsm(nodeVersion)) { + console.log(`Skipping: node ${nodeVersion} has no require(esm) support`); + return false; + } + return true; +} + +beforeAll(() => { + nodeVersion = detectNodeVersion(); + sandbox = createCommonJsSandbox(); +}); + +afterAll(() => { + removeCommonJsSandbox(sandbox); +}); + +describe('CommonJS entry point (issue #189)', () => { + describe('package manifest', () => { + test('declares a require condition pointing at the CommonJS entry', () => { + expect(PACKAGE_JSON.exports['.'].require).toBe('./src/$.cjs'); + expect(PACKAGE_JSON.exports['.'].import).toBe('./src/$.mjs'); + expect(PACKAGE_JSON.main).toBe('./src/$.cjs'); + expect(PACKAGE_JSON.module).toBe('./src/$.mjs'); + }); + + test('ships the CommonJS entry inside the published files', () => { + expect(PACKAGE_JSON.files).toContain('src/'); + expect(() => + readFileSync(join(PACKAGE_ROOT, 'src', '$.cjs'), 'utf8') + ).not.toThrow(); + }); + }); + + describe('in-process require()', () => { + const require = createRequire(import.meta.url); + + test('exports a callable $ with the named exports attached', () => { + const $ = require('../src/$.cjs'); + + expect(typeof $).toBe('function'); + expect($.$).toBe($); + expect($.default).toBe($); + expect($.__esModule).toBe(true); + for (const name of ['sh', 'exec', 'run', 'quote', 'create', 'register']) { + expect(typeof $[name]).toBe('function'); + } + expect(typeof $.ProcessRunner).toBe('function'); + expect(typeof $.shell).toBe('object'); + }); + + test('runs ProcessRunner.sync() synchronously', () => { + const $ = require('../src/$.cjs'); + const result = $({ mirror: false })`echo cjs-sync`.sync(); + + expect(result.stdout.trim()).toBe('cjs-sync'); + expect(result.code).toBe(0); + }); + + test('shares one module instance with the ESM entry point', async () => { + const $ = require('../src/$.cjs'); + const esm = await import('../src/$.mjs'); + + expect($.ProcessRunner).toBe(esm.ProcessRunner); + expect($.shell).toBe(esm.shell); + expect($({ mirror: false })`echo instance`).toBeInstanceOf( + esm.ProcessRunner + ); + }); + + test('does not mutate the $ function seen by ESM consumers', async () => { + const $ = require('../src/$.cjs'); + const esm = await import('../src/$.mjs'); + + expect($).not.toBe(esm.$); + expect(esm.$.sh).toBeUndefined(); + expect(esm.$.ProcessRunner).toBeUndefined(); + }); + }); + + describe('resolution from a CommonJS host', () => { + test('require("command-stream") resolves and runs .sync()', () => { + if (!canProbeWithNode()) { + return; + } + + const result = runSandboxScript(sandbox, 'probe.cjs', [ + "const $ = require('command-stream');", + "if (typeof $ !== 'function') {", + " throw new Error('expected require() to return a callable $');", + '}', + 'const probe = $({ mirror: false })`echo launch-probe`.sync();', + 'console.log(', + ' JSON.stringify({', + ' stdout: probe.stdout.trim(),', + ' code: probe.code,', + ' sh: typeof $.sh,', + ' })', + ');', + ]); + + // stderr is not required to be empty: Node.js 20 and 22 print an + // ExperimentalWarning when require() loads an ES module. + expect(result.stderr).not.toContain('ERR_REQUIRE_ESM'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + stdout: 'launch-probe', + code: 0, + sh: 'function', + }); + }); + + test('destructuring named exports works from require()', () => { + if (!canProbeWithNode()) { + return; + } + + const result = runSandboxScript(sandbox, 'named.cjs', [ + "const { $, sh, ProcessRunner, shell } = require('command-stream');", + 'const runner = $({ mirror: false })`echo named-exports`;', + 'const probe = runner.sync();', + 'console.log(', + ' JSON.stringify({', + ' stdout: probe.stdout.trim(),', + ' isRunner: runner instanceof ProcessRunner,', + ' sh: typeof sh,', + ' settings: typeof shell.settings(),', + ' })', + ');', + ]); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + stdout: 'named-exports', + isRunner: true, + sh: 'function', + settings: 'object', + }); + }); + + test('require() and import() return the same module instance', () => { + if (!canProbeWithNode()) { + return; + } + + const result = runSandboxScript(sandbox, 'instance.cjs', [ + "const $ = require('command-stream');", + "import('command-stream').then((esm) => {", + ' console.log(', + ' JSON.stringify({', + ' sameRunner: esm.ProcessRunner === $.ProcessRunner,', + ' sameShell: esm.shell === $.shell,', + ' })', + ' );', + '});', + ]); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + sameRunner: true, + sameShell: true, + }); + }); + }); +}); diff --git a/js/tests/commonjs-sandbox.mjs b/js/tests/commonjs-sandbox.mjs new file mode 100644 index 0000000..12e937b --- /dev/null +++ b/js/tests/commonjs-sandbox.mjs @@ -0,0 +1,112 @@ +// Shared helper for the CommonJS entry point tests (issue #189). +// +// Both the Bun suite (tests/commonjs-entry.test.mjs) and the Node.js suite +// (tests/node-commonjs-entry.mjs) need a throwaway package that resolves +// `command-stream` as a bare specifier, so the package.json "exports" +// conditions are exercised exactly the way a consumer would hit them. + +import { spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const PACKAGE_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '..' +); + +/** + * Whether a Node.js version can `require()` an ES module. + * + * require(esm) shipped unflagged in Node.js 20.19.0 and 22.12.0; older lines + * throw ERR_REQUIRE_ESM, which $.cjs converts into an actionable message. + * + * @param {string} version Node.js version string, with or without a leading v. + * @returns {boolean} True when require(esm) is supported. + */ +export function supportsRequireEsm(version) { + const [major, minor] = String(version) + .replace(/^v/, '') + .split('.') + .map(Number); + if (major > 22) { + return true; + } + if (major === 22) { + return minor >= 12; + } + if (major === 20) { + return minor >= 19; + } + return false; +} + +/** + * Resolve the version of the `node` executable on PATH. + * + * @returns {string | null} The version string, or null when node is missing. + */ +export function detectNodeVersion() { + try { + const probe = spawnSync('node', ['-p', 'process.versions.node'], { + encoding: 'utf8', + timeout: 10000, + }); + return probe.status === 0 ? probe.stdout.trim() : null; + } catch { + return null; + } +} + +/** + * Create a temporary package whose node_modules links back to this package. + * + * @returns {string} Absolute path of the sandbox directory. + */ +export function createCommonJsSandbox() { + const sandbox = mkdtempSync(join(tmpdir(), 'command-stream-cjs-')); + mkdirSync(join(sandbox, 'node_modules')); + // 'junction' keeps Windows runners working without developer mode. + symlinkSync( + PACKAGE_ROOT, + join(sandbox, 'node_modules', 'command-stream'), + 'junction' + ); + return sandbox; +} + +/** + * Remove a sandbox created by createCommonJsSandbox(). + * + * @param {string | null} sandbox Sandbox directory to delete. + */ +export function removeCommonJsSandbox(sandbox) { + if (sandbox) { + rmSync(sandbox, { force: true, recursive: true }); + } +} + +/** + * Write a CommonJS script into the sandbox and execute it with Node.js. + * + * @param {string} sandbox Sandbox directory. + * @param {string} name File name to write, e.g. 'probe.cjs'. + * @param {string[]} lines Source lines of the script. + * @returns {import('node:child_process').SpawnSyncReturns} Result. + */ +export function runSandboxScript(sandbox, name, lines) { + const scriptPath = join(sandbox, name); + writeFileSync(scriptPath, `${lines.join('\n')}\n`); + return spawnSync('node', [scriptPath], { + cwd: sandbox, + encoding: 'utf8', + timeout: 30000, + }); +} diff --git a/js/tests/node-commonjs-entry.mjs b/js/tests/node-commonjs-entry.mjs new file mode 100644 index 0000000..b8c4bae --- /dev/null +++ b/js/tests/node-commonjs-entry.mjs @@ -0,0 +1,35 @@ +// Node.js suite for the CommonJS entry point (issue #189). +// +// The Bun suite in commonjs-entry.test.mjs covers the resolved shape; this file +// runs under `node --test` so the CI Node.js matrix (20, 22, 24) proves that a +// real CommonJS host can require('command-stream') and call the synchronous +// ProcessRunner.sync() API without any await. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + createCommonJsSandbox, + removeCommonJsSandbox, + runSandboxScript, + supportsRequireEsm, +} from './commonjs-sandbox.mjs'; + +test('require("command-stream") exposes the synchronous API', (context) => { + if (!supportsRequireEsm(process.versions.node)) { + context.skip(`node ${process.versions.node} has no require(esm) support`); + return; + } + + const sandbox = createCommonJsSandbox(); + context.after(() => removeCommonJsSandbox(sandbox)); + + const result = runSandboxScript(sandbox, 'launch-probe.cjs', [ + "const $ = require('command-stream');", + 'const probe = $({ mirror: false })`echo node-cjs-probe`.sync();', + 'process.stdout.write(`${typeof $}:${probe.code}:${probe.stdout.trim()}`);', + ]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'function:0:node-cjs-probe'); +});