Skip to content
This repository was archived by the owner on Sep 30, 2026. It is now read-only.
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
28 changes: 26 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,19 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string
let progArgs = opts.progArgs ? `${opts.progArgs.join(` `)} ` : ''

// #!/bin/sh
// basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
// # Resolve $0 through symlinks so basedir is the shim's real directory.
// # Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim.
// link="$0"
// hops=0
// while [ -L "$link" ] && [ "$hops" -lt 40 ]; do
// hops=$((hops+1))
// target=$(readlink "$link")
// case "$target" in
// /*) link="$target" ;;
// *) link="$(dirname "$link")/$target" ;;
// esac
// done
// basedir=$(dirname "$(echo "$link" | sed -e 's,\\,/,g')")
// basedir_win="$basedir"
// exe=""
// msys=""
Expand Down Expand Up @@ -510,7 +522,19 @@ function generateShShim (src: string, to: string, opts: InternalOptions): string

let sh = `\
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")
# Resolve $0 through symlinks so basedir is the shim's real directory.
# Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim.
link="$0"
hops=0
while [ -L "$link" ] && [ "$hops" -lt 40 ]; do
hops=$((hops+1))
target=$(readlink "$link")
case "$target" in
/*) link="$target" ;;
*) link="$(dirname "$link")/$target" ;;
esac
done
basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
Expand Down
88 changes: 88 additions & 0 deletions test/e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,91 @@ describeOnPosix('sh shim uses POSIX runtime from PATH', () => {
assert.doesNotMatch(r.stderr, /node\.exe/)
})
})

describeOnPosix('sh shim invoked through a chain of external symlinks', () => {
// POSIX shells set $0 to the invoked symlink, not the shim it points at,
// so the shim must follow the chain before deriving basedir
// (https://github.com/pnpm/pnpm/issues/13405).
const makeShimmedTool = async (tempDir) => {
const binDir = path.join(tempDir, 'node_modules', '.bin')
const targetDir = path.join(tempDir, 'node_modules', 'typescript', 'bin')
fs.mkdirSync(binDir, { recursive: true })
fs.mkdirSync(targetDir, { recursive: true })

const target = path.join(targetDir, 'tsc')
fs.writeFileSync(target, '#!/bin/sh\necho "tsc-output"\n', 'utf8')
fs.chmodSync(target, 0o755)

const shim = path.join(binDir, 'tsc')
await cmdShim(target, shim)
return shim
}

const runShim = (cmd) => {
const r = spawnSync(cmd, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
assert.equal(r.status, 0, `shim exited ${r.status}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`)
return r.stdout.trim()
}

test('follows a chain of absolute symlinks', async () => {
const tempDir = tempy.directory()
const shim = await makeShimmedTool(tempDir)

const hop1 = path.join(tempDir, 'symlink_hop_1')
const hop2 = path.join(tempDir, 'symlink_hop_2')
fs.symlinkSync(shim, hop1)
fs.symlinkSync(hop1, hop2)

assert.equal(runShim(hop2), 'tsc-output')
})

test('follows relative symlinks from another directory', async () => {
const tempDir = tempy.directory()
await makeShimmedTool(tempDir)

// Both hops use relative targets, exercising the shim's
// dirname-composition branch rather than the absolute one.
const localBin = path.join(tempDir, 'usr', 'local', 'bin')
fs.mkdirSync(localBin, { recursive: true })
const hop1 = path.join(tempDir, 'usr', 'tsc')
fs.symlinkSync(path.join('..', 'node_modules', '.bin', 'tsc'), hop1)
const hop2 = path.join(localBin, 'tsc')
fs.symlinkSync(path.join('..', '..', 'tsc'), hop2)

assert.equal(runShim(hop2), 'tsc-output')
})

test('resolves symlinks when directories contain spaces', async () => {
const tempDir = path.join(tempy.directory(), 'dir with spaces')
fs.mkdirSync(tempDir, { recursive: true })
await makeShimmedTool(tempDir)

const hopDir = path.join(tempDir, 'link dir')
fs.mkdirSync(hopDir)
const hop = path.join(hopDir, 'tsc')
fs.symlinkSync(path.join('..', 'node_modules', '.bin', 'tsc'), hop)

assert.equal(runShim(hop), 'tsc-output')
})

test('runs a node-shebang bin through a symlink chain', async () => {
const tempDir = tempy.directory()
const binDir = path.join(tempDir, 'node_modules', '.bin')
const pkgDir = path.join(tempDir, 'node_modules', 'tool')
fs.mkdirSync(binDir, { recursive: true })
fs.mkdirSync(pkgDir, { recursive: true })

const target = path.join(pkgDir, 'cli.js')
fs.writeFileSync(target, '#!/usr/bin/env node\nconsole.log("NODE_BIN_OK")\n', 'utf8')
const shim = path.join(binDir, 'tool')
await cmdShim(target, shim, { createCmdFile: false })

const hop = path.join(tempDir, 'tool')
fs.symlinkSync(path.join('node_modules', '.bin', 'tool'), hop)

assert.equal(runShim(hop), 'NODE_BIN_OK')
})
})
14 changes: 13 additions & 1 deletion test/e2e.test.js.snapshot
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
exports[`create a command shim for a .exe file > shim files 1`] = `

#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")
# Resolve $0 through symlinks so basedir is the shim's real directory.
# Cap hops at the kernel's ELOOP limit so a cycle cannot hang the shim.
link="$0"
hops=0
while [ -L "$link" ] && [ "$hops" -lt 40 ]; do
hops=$((hops+1))
target=$(readlink "$link")
case "$target" in
/*) link="$target" ;;
*) link="$(dirname "$link")/$target" ;;
esac
done
basedir=$(dirname "$(echo "$link" | sed -e 's,\\\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
Expand Down
Loading
Loading