A Windows developer runs the project's setup task and gets 'rm' is not recognized as an internal or external command, or /usr/bin/env: 'bash\r': No such file or directory from a script, or a path like C:\Users\Ana Silva\src\shop split at the space. The tasks were written and tested on macOS and Linux, and each Windows developer has been quietly working around them. This page makes task runner targets work for Windows developers with one of three strategies and fixes the specific breakages, as part of task runners and an internal developer CLI.

The first decision is not technical: does the team support native Windows development, or is WSL2 the supported path? Both are legitimate, and each leads to a different, much simpler set of fixes than trying to support everything at once.

Diagnostic

On a Windows machine, check which shell the task runner uses and which constructs fail. This PowerShell block runs natively:

$ErrorActionPreference = 'Stop'
Get-Command just, task, make, bash, sh -ErrorAction SilentlyContinue | Format-Table Name, Source
git config --get core.autocrlf
Select-String -Path justfile, Taskfile.yml, scripts\*.sh -Pattern 'rm -rf|sed -i|\$\(pwd\)|/tmp/' -ErrorAction SilentlyContinue | Select-Object -First 8
(Get-Content scripts\doctor.sh -Raw) -match "`r`n"

Expected bad output:

Name  Source
----  ------
just  C:\Users\ana\scoop\shims\just.exe
bash  C:\Windows\System32\bash.exe
true
justfile:14:    rm -rf dist .cache
scripts\doctor.sh:3:TMP=/tmp/doctor-$$
True

bash resolves to the WSL launcher rather than a Windows Bash, core.autocrlf=true converted scripts to CRLF on checkout, and tasks use rm -rf, /tmp and other POSIX assumptions.

Where Windows Breaks POSIX Tasks Layers at which a POSIX-authored task fails on native Windows. Where Windows Breaks POSIX Tasks Line endings CRLF breaks shebangs Shell cmd or PowerShell, not bash Commands rm, sed -i, cp -r missing Paths backslashes, spaces, /tmp
Fix the layer that fails rather than rewriting every task.

Root cause

Task runners pass recipe lines to a shell. On macOS and Linux that is sh or bash; on Windows, just defaults to sh if one is found (often the WSL launcher, which runs commands in a different filesystem), Make needs an MSYS or Cygwin environment, and only Task ships its own portable interpreter. POSIX utilities such as rm, cp -r, sed -i and mktemp are not Windows commands. Git's core.autocrlf=true, the default in Git for Windows, rewrites line endings to CRLF on checkout, so a script's shebang becomes bash\r, which does not exist. And Windows paths contain backslashes and, often, spaces in user directories, which break unquoted variables. Each problem is small, but together they mean every task fails in a slightly different way.

Resolution

  1. Pin line endings in the repository so scripts keep LF everywhere, regardless of each developer's Git settings:
* text=auto
*.sh text eol=lf
justfile text eol=lf
Taskfile.yml text eol=lf
*.ps1 text eol=crlf
*.cmd text eol=crlf

Save as .gitattributes, then renormalise once:

#!/usr/bin/env bash
set -euo pipefail
git add --renormalize .
git status --short | head
git commit -m "Normalise line endings with .gitattributes"
  1. Pick one strategy and document it.

    • WSL2 is the supported path. Tasks stay POSIX; Windows developers clone into the WSL filesystem and run everything there. Add a doctor check that fails when run from native Windows.
    • Task with its embedded shell. Recipes use POSIX syntax that Task's interpreter implements (rm, mkdir -p, cp), so they run in PowerShell or cmd without Bash.
    • Scripts in a cross-platform language. Tasks call node scripts/clean.mjs or python scripts/clean.py, and all logic lives in code that handles paths correctly.
  2. Replace the constructs that break most often when supporting native Windows through scripts:

import { rmSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

for (const dir of ['dist', '.cache']) {
  rmSync(dir, { recursive: true, force: true });
}
const scratch = mkdtempSync(join(tmpdir(), 'shop-'));
console.log(`cleaned; scratch dir ${scratch}`);

rmSync with force replaces rm -rf, os.tmpdir() replaces /tmp, and path.join produces correct separators on each platform.

  1. Configure just's Windows shell explicitly if just is the tool and native Windows is supported:
set windows-shell := ["pwsh.exe", "-NoLogo", "-NoProfile", "-Command"]
set shell := ["bash", "-euo", "pipefail", "-c"]

# Remove build output on any OS
clean:
    node scripts/clean.mjs
  1. Quote every path. In any recipe that must stay in shell, wrap variables in double quotes ("$PWD/dist"), which handles spaces in Windows user names.
Choosing a Windows Strategy Decision diagram selecting WSL2-only, Task's embedded shell or cross-platform scripts. Choosing a Windows Strategy Can Windows devs use WSL2? Yes POSIX tasks in WSL2 No, simple tasks Task embedded shell No, complex logic Node or Python scripts
One strategy per repository keeps tasks simple and testable.

Expected output

On native Windows with the Task or script strategy:

PS C:\Users\Ana Silva\src\shop> task clean
task: [clean] rm -rf dist .cache
PS C:\Users\Ana Silva\src\shop> just clean
node scripts/clean.mjs
cleaned; scratch dir C:\Users\ANASIL~1\AppData\Local\Temp\shop-Xk3p9Q
PS C:\Users\Ana Silva\src\shop> (Get-Content scripts\doctor.sh -Raw) -match "`r`n"
False

Tasks complete from PowerShell, paths with spaces work, and scripts keep LF line endings after checkout.

The temporary-directory line is a good smoke test on its own: it proves the script found the Windows temp location rather than a hard-coded /tmp, and that the short-name path containing the user's space was handled correctly. When adding new tasks, run them once from a user directory with a space in its name — most path-quoting bugs appear only there, and many CI Windows runners use a profile path without spaces that hides them.

Prevention

  1. Run tasks on a Windows CI runner. A windows-latest job that runs task --list and the core tasks catches POSIX-only additions immediately. If WSL2 is the strategy, run the job in WSL on the runner instead.

  2. Lint for POSIX-only constructs in recipe bodies when native Windows is supported:

#!/usr/bin/env bash
set -euo pipefail
if grep -nE '^\s+(sed -i|mktemp|readlink -f|xargs -r)' justfile; then
  echo "POSIX-only construct in a recipe; move it into a script"; exit 1
fi
echo "recipes portable"
  1. Keep .gitattributes authoritative so line endings never depend on individual Git configuration; the line-ending guide covers existing clones.
Core Tasks Passing on Native Windows Bar chart of how many of twelve core tasks pass on native Windows before and after the fixes. Core Tasks Passing on Native Windows before 3 of 12 after gitattributes 7 of 12 after scripts 12 of 12
One team's repository; line endings and rm or sed usage accounted for most failures.

Platform caveats

WSL2: clone into the Linux filesystem (~/src), not /mnt/c/.... Tasks run far faster, file watchers work, and line-ending problems largely disappear because Git inside WSL uses Linux defaults.

PowerShell 5 vs 7: Windows ships PowerShell 5.1; pwsh is PowerShell 7. Recipes that use newer syntax such as && chaining need pwsh. Name the executable explicitly in windows-shell.

Docker Desktop on Windows: docker compose works from both PowerShell and WSL; volume paths in Compose files should be relative (./data) so they resolve on either side.

Rollback

Each change is independent. Remove .gitattributes rules or revert script changes if they cause problems; the POSIX tasks still work on macOS and Linux:

#!/usr/bin/env bash
set -euo pipefail
git revert --no-edit "$(git log -1 --format=%H -- .gitattributes)"

Frequently Asked Questions

Why does my script fail with bash\r: No such file or directory?

The file was checked out with CRLF line endings, so the shebang line ends with a carriage return. Add *.sh text eol=lf to .gitattributes and run git add --renormalize ., then re-checkout the file.

Should we just require WSL2 for Windows developers?

It is often the simplest option: tasks stay POSIX, performance is good when the repository lives in the Linux filesystem, and Docker integrates well. Choose native support only if developers cannot use WSL2 or the tooling requires Windows.

Does Task's embedded shell support everything Bash does?

It supports POSIX shell syntax and common built-ins such as rm, mkdir, cp and mv, but not Bash-only features or arbitrary Unix utilities. Keep complex logic in scripts.

Why does bash in PowerShell open WSL?

C:\Windows\System32\bash.exe is the WSL launcher. just and other tools may pick it up as their shell, running commands in the Linux filesystem with different paths. Set the Windows shell explicitly to avoid it.