diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 3eba852..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .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 diff --git a/js/.changeset/lightweight-process-runner.md b/js/.changeset/lightweight-process-runner.md new file mode 100644 index 0000000..cdc3f5d --- /dev/null +++ b/js/.changeset/lightweight-process-runner.md @@ -0,0 +1,5 @@ +--- +'command-stream': patch +--- + +Add a lightweight `command-stream/process-runner` export that excludes optional PTY and terminal rendering dependencies from its module graph. diff --git a/js/README.md b/js/README.md index b1aeee5..66ebeed 100644 --- a/js/README.md +++ b/js/README.md @@ -156,6 +156,24 @@ npm install command-stream bun add command-stream ``` +### Lightweight ProcessRunner entry point + +Consumers that only need direct process execution can import `ProcessRunner` +without loading the optional PTY, terminal rendering, SVG, or GIF modules: + +```javascript +import { ProcessRunner } from 'command-stream/process-runner'; + +const runner = new ProcessRunner( + { mode: 'exec', file: 'git', args: ['status', '--short'] }, + { mirror: false, capture: true, stdin: 'ignore' } +); +const result = await runner; +``` + +The main `command-stream` entry point remains the supported import for `$` and +terminal capture features. + ## Smart Quoting & Security Command-stream provides intelligent auto-quoting to protect against shell injection while avoiding unnecessary quotes for safe strings: diff --git a/js/package.json b/js/package.json index fc5943f..7410fb4 100644 --- a/js/package.json +++ b/js/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "src/$.mjs", "exports": { - ".": "./src/$.mjs" + ".": "./src/$.mjs", + "./process-runner": "./src/process-runner.mjs" }, "repository": { "type": "git", diff --git a/js/src/$.mjs b/js/src/$.mjs index f726b0b..be160c5 100755 --- a/js/src/$.mjs +++ b/js/src/$.mjs @@ -5,7 +5,6 @@ import { trace } from './$.trace.mjs'; import { globalShellSettings, virtualCommands, - isVirtualCommandsEnabled, enableVirtualCommands as enableVirtualCommandsState, disableVirtualCommands as disableVirtualCommandsState, forceCleanupAll, @@ -25,32 +24,7 @@ import { unrollTerminalFrames, } from './terminal-capture.mjs'; -// Import ProcessRunner base and method modules -import { ProcessRunner } from './$.process-runner-base.mjs'; -import { attachExecutionMethods } from './$.process-runner-execution.mjs'; -import { attachPipelineMethods } from './$.process-runner-pipeline.mjs'; -import { attachOrchestrationMethods } from './$.process-runner-orchestration.mjs'; -import { attachVirtualCommandMethods } from './$.process-runner-virtual.mjs'; -import { attachStreamKillMethods } from './$.process-runner-stream-kill.mjs'; - -// Create dependencies object for method attachment -const deps = { - virtualCommands, - globalShellSettings, - isVirtualCommandsEnabled, -}; - -// Attach all methods to ProcessRunner prototype using mixin pattern -attachExecutionMethods(ProcessRunner, deps); -attachPipelineMethods(ProcessRunner, deps); -attachOrchestrationMethods(ProcessRunner, deps); -attachVirtualCommandMethods(ProcessRunner, deps); -attachStreamKillMethods(ProcessRunner, deps); - -trace( - 'Initialization', - () => 'ProcessRunner methods attached via mixin pattern' -); +import { ProcessRunner } from './process-runner.mjs'; // Public APIs async function sh(commandString, options = {}) { diff --git a/js/src/process-runner.mjs b/js/src/process-runner.mjs new file mode 100644 index 0000000..09ef4cf --- /dev/null +++ b/js/src/process-runner.mjs @@ -0,0 +1,33 @@ +// Lightweight ProcessRunner entry point without terminal capture dependencies + +import { ProcessRunner } from './$.process-runner-base.mjs'; +import { attachExecutionMethods } from './$.process-runner-execution.mjs'; +import { attachOrchestrationMethods } from './$.process-runner-orchestration.mjs'; +import { attachPipelineMethods } from './$.process-runner-pipeline.mjs'; +import { attachStreamKillMethods } from './$.process-runner-stream-kill.mjs'; +import { attachVirtualCommandMethods } from './$.process-runner-virtual.mjs'; +import { + globalShellSettings, + isVirtualCommandsEnabled, + virtualCommands, +} from './$.state.mjs'; +import { trace } from './$.trace.mjs'; + +const dependencies = { + virtualCommands, + globalShellSettings, + isVirtualCommandsEnabled, +}; + +attachExecutionMethods(ProcessRunner, dependencies); +attachPipelineMethods(ProcessRunner, dependencies); +attachOrchestrationMethods(ProcessRunner, dependencies); +attachVirtualCommandMethods(ProcessRunner, dependencies); +attachStreamKillMethods(ProcessRunner, dependencies); + +trace( + 'Initialization', + () => 'ProcessRunner methods attached via mixin pattern' +); + +export { ProcessRunner }; diff --git a/js/tests/process-runner-entry.test.mjs b/js/tests/process-runner-entry.test.mjs new file mode 100644 index 0000000..fa523ba --- /dev/null +++ b/js/tests/process-runner-entry.test.mjs @@ -0,0 +1,92 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const packageDirectory = resolve(testDirectory, '..'); +const packageJson = JSON.parse( + readFileSync(resolve(packageDirectory, 'package.json'), 'utf8') +); +const processRunnerExport = packageJson.exports['./process-runner']; +const terminalDependencies = new Set([ + '@resvg/resvg-js', + '@xterm/headless', + 'gifenc', + 'node-pty', +]); + +function collectModuleGraph(entrypoint) { + const pending = [entrypoint]; + const modules = new Set(); + const packages = new Set(); + const importPattern = + /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s*)['"]([^'"]+)['"]/g; + + while (pending.length > 0) { + const modulePath = pending.pop(); + if (modules.has(modulePath)) { + continue; + } + modules.add(modulePath); + + const source = readFileSync(modulePath, 'utf8'); + for (const match of source.matchAll(importPattern)) { + const specifier = match[1]; + if (specifier.startsWith('.')) { + pending.push(resolve(dirname(modulePath), specifier)); + } else if (!specifier.startsWith('node:')) { + packages.add(specifier); + } + } + } + + return { modules, packages }; +} + +test('exports a fully initialized ProcessRunner subpath', async () => { + expect(processRunnerExport).toBe('./src/process-runner.mjs'); + + const { ProcessRunner } = await import('command-stream/process-runner'); + for (const method of [ + 'start', + '_runPipeline', + 'pipe', + '_runVirtual', + 'stream', + 'kill', + ]) { + expect(typeof ProcessRunner.prototype[method]).toBe('function'); + } + + const runner = new ProcessRunner( + { + mode: 'exec', + file: process.execPath, + args: ['-e', "process.stdout.write('lightweight runner')"], + }, + { capture: true, mirror: false, stdin: 'ignore' } + ); + const result = await runner; + + expect(result.code).toBe(0); + expect(result.stdout).toBe('lightweight runner'); +}); + +test('keeps terminal features out of the ProcessRunner module graph', () => { + if (!processRunnerExport) { + throw new Error('command-stream/process-runner is not exported'); + } + + const entrypoint = resolve(packageDirectory, processRunnerExport); + const graph = collectModuleGraph(entrypoint); + const terminalModules = [...graph.modules] + .map((modulePath) => relative(packageDirectory, modulePath)) + .filter((modulePath) => modulePath.includes('terminal-')); + const importedTerminalDependencies = [...graph.packages].filter((specifier) => + terminalDependencies.has(specifier) + ); + + expect(terminalModules).toEqual([]); + expect(importedTerminalDependencies).toEqual([]); +});