From 59b92f9b8f7228c11944c1823a6d1aa78d0711ef Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:41:56 +0100 Subject: [PATCH 1/2] fix: remove normalisation of commands This removes `normalize(...)` around a command as I suspect this is a bug. Basically, `x('./foo')` would result in `x('foo')` as far as I can tell. Which means the OS will search `PATH` for `foo` rather than calling exactly the one in `cwd`. This has existed since the beginning of tinyexec, so it doesn't seem to be a noticed bug. I also can't think of a reason we need to `normalize` since Windows and cross-spawn handle slashes, relative resolution, etc. --- src/main.ts | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/main.ts b/src/main.ts index 39b0212..443ee1a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,6 @@ import { type SpawnSyncOptions } from 'node:child_process'; import {type Readable} from 'node:stream'; -import {normalize as normalizePath} from 'node:path'; import {cwd as getCwd} from 'node:process'; import {computeEnv} from './env.js'; import {combineStreams} from './stream.js'; @@ -90,22 +89,6 @@ const defaultNodeOptions: SpawnOptions = { windowsHide: true }; -function normaliseCommandAndArgs( - command: string, - args?: readonly string[] -): { - command: string; - args: readonly string[]; -} { - const normalisedPath = normalizePath(command); - const normalisedArgs = args ?? []; - - return { - command: normalisedPath, - args: normalisedArgs - }; -} - function combineSignals(signals: Iterable): AbortSignal { const controller = new AbortController(); @@ -329,10 +312,7 @@ export class ExecProcess implements Result { nodeOptions.env = computeEnv(cwd, nodeOptions.env); - const {command: normalisedCommand, args: normalisedArgs} = - normaliseCommandAndArgs(this._command, this._args); - - const crossResult = _parse(normalisedCommand, normalisedArgs, nodeOptions); + const crossResult = _parse(this._command, this._args, nodeOptions); const handle = spawn( crossResult.command, @@ -406,10 +386,7 @@ export function xSync( nodeOptions.env = computeEnv(cwd, nodeOptions.env); - const {command: normalisedCommand, args: normalisedArgs} = - normaliseCommandAndArgs(command, args); - - const crossResult = _parse(normalisedCommand, normalisedArgs, nodeOptions); + const crossResult = _parse(command, args ?? [], nodeOptions); const spawnResult = spawnSync( crossResult.command, From b05239bd186f21637226bef359c4cad5605a25db Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:54:20 +0100 Subject: [PATCH 2/2] test: add relative path tests --- src/test/main_test.ts | 72 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/test/main_test.ts b/src/test/main_test.ts index e5264ce..9150777 100644 --- a/src/test/main_test.ts +++ b/src/test/main_test.ts @@ -1,6 +1,8 @@ import {x, xSync, ExecProcess, NonZeroExitError} from '../main.js'; import {describe, test, expect} from 'vitest'; import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; const isWindows = os.platform() === 'win32'; @@ -209,6 +211,23 @@ if (isWindows) { 'operable program or batch file.' ]); }); + + test('preserves leading ./ so cwd-local binary is run, not PATH lookup', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-relpath-')); + try { + const scriptPath = path.join(dir, 'mytool.cmd'); + fs.writeFileSync(scriptPath, '@echo local\r\n'); + + const result = await x('./mytool.cmd', [], { + nodeOptions: {cwd: dir} + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('local\r\n'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); }); describe('exec (windows) (sync)', () => { @@ -231,6 +250,23 @@ if (isWindows) { 'operable program or batch file.' ]); }); + + test('preserves leading ./ so cwd-local binary is run, not PATH lookup', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-relpath-')); + try { + const scriptPath = path.join(dir, 'mytool.cmd'); + fs.writeFileSync(scriptPath, '@echo local\r\n'); + + const result = xSync('./mytool.cmd', [], { + nodeOptions: {cwd: dir} + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('local\r\n'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); }); } @@ -295,6 +331,24 @@ if (!isWindows) { } }).rejects.toThrow(); }); + + test('preserves leading ./ so cwd-local binary is run, not PATH lookup', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-relpath-')); + try { + const scriptPath = path.join(dir, 'mytool'); + fs.writeFileSync(scriptPath, '#!/bin/sh\necho local\n'); + fs.chmodSync(scriptPath, 0o755); + + const result = await x('./mytool', [], { + nodeOptions: {cwd: dir, env: {PATH: '/usr/bin:/bin'}} + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('local\n'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); }); describe('exec (unix-like) (sync)', () => { @@ -319,5 +373,23 @@ if (!isWindows) { xSync('nonexistentforsure'); }).toThrow(); }); + + test('preserves leading ./ so cwd-local binary is run, not PATH lookup', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tinyexec-relpath-')); + try { + const scriptPath = path.join(dir, 'mytool'); + fs.writeFileSync(scriptPath, '#!/bin/sh\necho local\n'); + fs.chmodSync(scriptPath, 0o755); + + const result = xSync('./mytool', [], { + nodeOptions: {cwd: dir, env: {PATH: '/usr/bin:/bin'}} + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('local\n'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); }); }