33

I have several PowerShell scripts that I'd like to invoke directly as a command from a Bash shell in Cygwin. For example, if I write a script with the filename Write-Foo.ps1, I'd like to execute it as a command from any working directory:

$ Write-Foo.ps1 arg1 arg2 ...

To do this, I add the script to my PATH, make it executable, and include the following interpreter shebang/hashbang at the beginning of the file:

#!/usr/bin/env powershell

Write-Host 'Foo'
...

It's a common (ab)use of the env utility, but it decouples the scripts from Cygwin's path prefix (/cygdrive/c/...), at least for the interpreter declaration.

This works to start PowerShell, but the system passes the file as a Cygwin-formatted path, which PowerShell doesn't understand, of course:

The term '/cygdrive/c/path/to/Write-Foo.ps1' is not recognized as the name of a cmdlet, function, script file, or operable program.

MSYS (Git Bash) seems to translate the script path correctly, and the script executes as expected, as long as the path to the file contains no spaces. Is there a way to invoke a PowerShell script directly by relying on the shebang in Cygwin?

Ideally, I'd also like to omit the .ps1 extension from the script names if possible, but I understand that I may need to live with this limitation. I want to avoid manually aliasing or wrapping the scripts if possible.


A quick note for Linux/macOS users finding this.

Cy Rossignol
  • 16,216
  • 4
  • 57
  • 83
  • 3
    Have you tried [PowerShell Core 6.0 by any chance? It was just released yesterday](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-core-60?view=powershell-5.1). They specifically changed positional parameter 0 from `-Command` to `-File` to make that work. – briantist Jan 11 '18 at 21:40
  • 1
    Yeah shebang support isn't really there until PS 6, which is literally less than a week out of release. Given the things PS 6 (and .Net Core) lack I would still not call it production ready. I would consider this a "not now, but eventually". – Bacon Bits Jan 11 '18 at 21:42
  • @briantist Ooh la la... looks interesting. I haven't tried it...need to take some time to play with that. I expect that v6 won't solve this particular problem, though. I have a feeling this is a challenge inherent to the Cygwin environment that may need to be handled before control reaches PowerShell. – Cy Rossignol Jan 11 '18 at 22:17
  • 1
    @CyRossignol I can't say for sure that the new version will fix this, but besides the shebang-focused change, it is supported on Linux/MacOS, and so its understanding and support of paths was updated to support those platforms. But really, the error message you're getting _is_ because it's passing a path to `-Command`, which won't work. Maybe you could alias `powershell` to implicitly call `powershell -File` instead? I don't know enough offhand about bash aliases to know if that's possible or will work in this scenario. – briantist Jan 11 '18 at 22:21
  • @briantist Thanks, good info and suggestion. That alias will work, but I'd still need to pass the path to the script, which I hope to avoid. Interestingly, I just tried `powershell Write-Foo`, which achieves this goal. Because I put that script into the environment's PATH, it seems like PS finds it and accepts the script as a valid command (even without the extension). I'd still like to get the shebang working, but this way is already much better. – Cy Rossignol Jan 11 '18 at 23:15
  • @CyRossignol in that case you'd be even better off writing your script as a function, inside a well-formed module in the module path. That way you can call `powershell -command "Import-Module MyModule ; Invoke-MyFunction"` ; or at least, _I_ think that's better :) – briantist Jan 11 '18 at 23:18
  • @briantist Hmm...good idea. I've been meaning to convert some of this stuff. Do you happen to know if PS reads the *profile.ps1* with a `-Command` argument? It might same some keystrokes. – Cy Rossignol Jan 12 '18 at 03:06
  • @CyRossignol as far as I know the 4 profiles are essentially dot sourced, but not with any parameters. – briantist Jan 12 '18 at 15:44

1 Answers1

45

Quick note for Linux/macOS users finding this:

  • Ensure the pwsh or powershell command is in PATH
  • Use this interpreter directive: #!/usr/bin/env pwsh
  • Ensure the script uses Unix-style line endings (\n, not \r\n)

Thanks to briantist's comments, I now understand that this isn't directly supported for PowerShell versions earlier than 6.0 without compromises:

...[in PowerShell Core 6.0] they specifically changed positional parameter 0 from ‑Command to ‑File to make that work. ...the error message you're getting is because it's passing a path to ‑Command...

A Unix-like system passes the PowerShell script's absolute filename to the interpreter specified by the "shebang" as the first argument when we invoke the script as a command. In general, this can sometimes work for PowerShell 5 and below because PowerShell, by default, interprets the script filename as the command to execute.

However, we cannot rely on this behavior because when PowerShell's handles -Command in this context, it re-interprets the filename as if it was typed at the prompt, so the path of a script that contains spaces or certain symbols will break the "command" that PowerShell sees as the argument. We also lose a bit of efficiency for the preliminary interpretation step.

When specifying the -File parameter instead, PowerShell loads the script directly, so we can avoid the problems we experience with -Command. Unfortunately, to use this option in the shebang line, we need to sacrifice the portability we gain by using the env utility described in the question because operating system program loaders usually allow only one argument to the program declared in the script for the interpreter.

For example, the following interpreter directive is invalid because it passes two arguments to the env command (powershell and -File):

#!/usr/bin/env powershell -File

In an MSYS system (like Git Bash), on the other hand, a PowerShell script that contains the following directive (with the absolute path to PowerShell) executes as expected:

#!/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -File

...but we cannot directly execute the script on another system that doesn't follow the same filesystem convention.


This also doesn't fix the original problem in Cygwin. As described in the question, the path to the script itself isn't translated to a Windows-style path, so PowerShell cannot locate the file (even in version 6). I figured out a couple of workarounds, but neither provide a perfect solution.

The simplest approach just exploits the default behavior of PowerShell's -Command parameter. After adding the Write-Foo.ps1 script to the environment's command search path (PATH), we can invoke PowerShell with the script name, sans the extension:

$ powershell Write-Foo arg1 arg2 ...

As long as the script file itself doesn't contain spaces in the filename, this allows us to run the script from any working directory—no shebang needed. PowerShell uses a native routine to resolve the command from the PATH, so we don't need to worry about spaces in the parent directories. We lose Bash's tab-completion for the command name, though.

To get the shebang to work in Cygwin, I needed to write a proxy script that converts the path style of the invoked script to a format that PowerShell understands. I called it pwsh (for portability with PS 6) and placed it in the PATH:

#!/bin/sh

if [ ! -f "$1" ]; then 
    exec "$(command -v pwsh.exe || command -v powershell.exe)" "$@"
    exit $?
fi

script="$(cygpath -w "$1")"
shift

if command -v pwsh.exe > /dev/null; then 
    exec pwsh.exe "$script" "$@"
else
    exec powershell.exe -File "$script" "$@"
fi

The script begins by checking the first argument. If it isn't a file, we just start PowerShell normally. Otherwise, the script translates the filename to a Windows-style path. This example falls back to powershell.exe if pwsh.exe from version 6 isn't available. Then we can use the following interpreter directive in the script...

#!/usr/bin/env pwsh

...and invoke a script directly:

$ Write-Foo.ps1 arg1 arg2 ...

For PowerShell versions before 6.0, the script can be extended to symlink or write out a temporary PowerShell script with a .ps1 extension if we want to create the originals without an extension.

Cy Rossignol
  • 16,216
  • 4
  • 57
  • 83