import * as plugins from './plugins.js'; const __dirname = plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url)); export interface IRunOptions { cwd?: string; } export interface ISpawnOptions { /** Working directory for the child process */ cwd?: string; /** Environment variables (merged with parent's env) */ env?: Record; /** Additional CLI arguments to pass to the script */ args?: string[]; /** * Stdio configuration * - 'pipe': Create pipes for stdin/stdout/stderr (default) * - 'inherit': Use parent's stdio * - Array: Custom configuration per stream */ stdio?: 'pipe' | 'inherit' | ['pipe' | 'inherit' | 'ignore', 'pipe' | 'inherit' | 'ignore', 'pipe' | 'inherit' | 'ignore']; /** * Optional timeout in milliseconds * If provided, process is automatically killed after timeout */ timeout?: number; /** * AbortSignal for cancellation support * Allows external cancellation of the process */ signal?: AbortSignal; } export interface ITsrunChildProcess { /** Direct access to Node's ChildProcess object */ childProcess: plugins.ChildProcess; /** Readable stream for stdout (null if stdio is 'inherit') */ stdout: plugins.Readable | null; /** Readable stream for stderr (null if stdio is 'inherit') */ stderr: plugins.Readable | null; /** Promise that resolves with the exit code when process ends */ exitCode: Promise; /** * Send signal to process * Returns true if signal was sent successfully */ kill(signal?: NodeJS.Signals): boolean; /** * Gracefully terminate the process * Tries SIGTERM first, waits 5s, then SIGKILL if still running * Returns a promise that resolves when process is terminated */ terminate(): Promise; } export const runPath = async (pathArg: string, fromFileUrl?: string, options?: IRunOptions) => { pathArg = fromFileUrl ? plugins.path.join(plugins.path.dirname(plugins.url.fileURLToPath(fromFileUrl)), pathArg) : pathArg; await runCli(pathArg, options); }; export const runCli = async (pathArg?: string, options?: IRunOptions) => { // CRITICAL: Branch BEFORE splicing argv to avoid corruption if (options?.cwd) { return runInChildProcess(pathArg, options.cwd); } // Existing in-process execution // contents of argv array // process.argv[0] -> node Executable // process.argv[1] -> tsrun executable const relativePathToTsFile = pathArg ? pathArg : process.argv[2]; const absolutePathToTsFile = plugins.path.isAbsolute(relativePathToTsFile) ? relativePathToTsFile : plugins.path.join(process.cwd(), relativePathToTsFile); // we want to have command line arguments available in the child process. // when we have a path sepcified through a function there is one argeument less to pay respect to. // thus when pathArg is specifed -> we only splice 2 pathArg ? process.argv.splice(0, 2) : process.argv.splice(0, 3); // this ensures transparent arguments for the child process const tsx = await import('tsx/esm/api'); const unregister = tsx.register(); await import(absolutePathToTsFile); }; const runInChildProcess = async (pathArg: string | undefined, cwd: string): Promise => { const { spawn } = await import('child_process'); // Resolve cli.child.js relative to this file const cliChildPath = plugins.path.join(__dirname, '../cli.child.js'); // Build args: [Node flags, entry point, script path, script args] const args = [ ...process.execArgv, // Preserve --inspect, etc. cliChildPath, ...process.argv.slice(2) // Original CLI args (not spliced) ]; return new Promise((resolve, reject) => { const child = spawn(process.execPath, args, { cwd: cwd, env: process.env, stdio: 'inherit', shell: false, windowsHide: false }); // Signal forwarding with cleanup const signalHandler = (signal: NodeJS.Signals) => { try { child.kill(signal); } catch {} }; const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']; signals.forEach(sig => process.on(sig, signalHandler)); child.on('error', (err) => { signals.forEach(sig => process.off(sig, signalHandler)); reject(err); }); child.on('close', (code, signal) => { // Clean up signal handlers signals.forEach(sig => process.off(sig, signalHandler)); if (signal) { // Child was terminated by signal // On POSIX: try to exit with same signal // On Windows: exit with convention (128 + signal number) try { process.kill(process.pid, signal); } catch { // Fallback to exit code const signalExitCode = signal === 'SIGINT' ? 130 : 128; process.exit(signalExitCode); } } else if (code !== null && code !== 0) { process.exit(code); } else { resolve(); } }); }); }; export const spawnPath = ( filePath: string, fromFileUrl?: string | URL, options?: ISpawnOptions ): ITsrunChildProcess => { // 1. Resolve path (similar to runPath) const resolvedPath = fromFileUrl ? plugins.path.join( plugins.path.dirname( plugins.url.fileURLToPath( typeof fromFileUrl === 'string' ? fromFileUrl : fromFileUrl.href ) ), filePath ) : filePath; // 2. Build spawn args const cliChildPath = plugins.path.join(__dirname, '../cli.child.js'); const args = [ ...process.execArgv, cliChildPath, resolvedPath, ...(options?.args || []) ]; // 3. Build spawn options const spawnOptions = { cwd: options?.cwd || process.cwd(), env: { ...process.env, ...options?.env }, stdio: options?.stdio || 'pipe', shell: false, windowsHide: false }; // 4. Spawn child process const child = plugins.spawn(process.execPath, args, spawnOptions); // 5. Set up timeout if provided let timeoutId: NodeJS.Timeout | undefined; let timeoutTriggered = false; if (options?.timeout) { timeoutId = setTimeout(() => { timeoutTriggered = true; child.kill('SIGTERM'); }, options.timeout); } // 6. Set up AbortSignal if provided let abortHandler: (() => void) | undefined; if (options?.signal) { abortHandler = () => { child.kill('SIGTERM'); }; options.signal.addEventListener('abort', abortHandler); } // 7. Create exitCode promise const exitCodePromise = new Promise((resolve, reject) => { child.on('close', (code: number | null, signal: NodeJS.Signals | null) => { if (timeoutId) clearTimeout(timeoutId); if (abortHandler && options?.signal) { options.signal.removeEventListener('abort', abortHandler); } if (timeoutTriggered) { reject(new Error(`Process killed: timeout of ${options?.timeout}ms exceeded`)); } else if (options?.signal?.aborted) { reject(new Error('Process killed: aborted by signal')); } else if (signal) { reject(new Error(`Process killed with signal ${signal}`)); } else { resolve(code || 0); } }); child.on('error', (err: Error) => { if (timeoutId) clearTimeout(timeoutId); if (abortHandler && options?.signal) { options.signal.removeEventListener('abort', abortHandler); } reject(err); }); }); // 8. Implement terminate() method const terminate = async (): Promise => { return new Promise((resolve) => { if (child.killed) { resolve(); return; } child.kill('SIGTERM'); const killTimeout = setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 5000); child.on('close', () => { clearTimeout(killTimeout); resolve(); }); }); }; // 9. Return ITsrunChildProcess object return { childProcess: child, stdout: child.stdout, stderr: child.stderr, exitCode: exitCodePromise, kill: (signal?: NodeJS.Signals) => child.kill(signal), terminate }; };