PowerShell: Join-Path cadena vacía con $PSScriptRoot

Un script de PowerShell funciona en tus pruebas, pero al lanzarlo con doble clic desde un archivo .bat termina con este error:

Join-Path : No se puede enlazar el argumento con el parámetro 'Path' porque es una cadena vacía.

En un sistema en inglés, el mensaje equivalente es Cannot bind argument to parameter 'Path' because it is an empty string. El origen suele estar en una línea como esta, dentro de param():

param(
    [string]$ConfigPath = (Join-Path $PSScriptRoot 'config.json')
)

Las pruebas pasaron porque siempre recibían -ConfigPath, así que el valor por defecto nunca se evaluó. Este artículo explica cuándo $PSScriptRoot llega vacío, cómo escribir el script para que no dependa de ese momento y cómo lanzarlo correctamente desde un .bat en Windows PowerShell 5.1.

Por qué pasa

Según la documentación de PowerShell, $PSScriptRoot contiene la ruta completa de la carpeta del script que se está ejecutando. En PowerShell 2.0 solo era válida en módulos de script (.psm1); desde PowerShell 3.0 es válida en todos los scripts. El mensaje de error indica que su valor es una cadena vacía (no $null): Join-Path recibió "" en -Path.

Dos hechos explican el síntoma:

  • El valor por defecto solo se calcula si no pasas el argumento. En una prueba con Windows PowerShell 5.1 y $PSScriptRoot vacío, pasar -ConfigPath 'x.json' evitó el error, y omitir el parámetro lo provocó. Por eso las pruebas que siempre pasaban el parámetro no detectaron el problema.
  • $PSScriptRoot está vacío cuando el código no se ejecuta como un archivo .ps1. En pruebas con Windows PowerShell 5.1 se comprobó que, ejecutando el archivo con -File, con el operador & o con un punto (dot-sourcing), la variable contiene la carpeta del script, incluso dentro de los valores por defecto de param(). En cambio, queda vacía cuando el texto del script se ejecuta sin ser un archivo: con Invoke-Expression sobre el contenido, con un bloque de script creado a partir de texto o leyendo el script desde la entrada estándar. En esos casos $MyInvocation.MyCommand.Path también está vacío.

Podría suponerse que la causa es un defecto de Windows PowerShell 5.1 al evaluar los valores por defecto de param(). No se encontró documentación oficial que lo confirme y, con -File, no se logró reproducirlo en las pruebas descritas, así que este artículo no lo afirma como causa. Lo que sí es comprobable es que un lanzador que no ejecuta el archivo como tal (por ejemplo, uno que pasa el contenido del script a -Command o por la entrada estándar) deja $PSScriptRoot vacío. Si tu caso es distinto, la solución del cuerpo del script, más abajo, sigue siendo la opción más segura porque fallas con un mensaje claro en lugar de un error de enlace de parámetros.

Solución

  1. Deja el parámetro sin valor calculado y resuélvelo en el cuerpo del script. Con un fallback y un error explícito si no se puede determinar la carpeta:

    param(
        [string]$ConfigPath
    )
    
    if (-not $ConfigPath) {
        $baseDir = $PSScriptRoot
        if (-not $baseDir -and $MyInvocation.MyCommand.Path) {
            $baseDir = Split-Path -Parent $MyInvocation.MyCommand.Path
        }
        if (-not $baseDir) {
            throw 'No se pudo determinar la carpeta del script. Ejecútalo con -File o pasa -ConfigPath.'
        }
        $ConfigPath = Join-Path $baseDir 'config.json'
    }

    El fallback Split-Path -Parent $MyInvocation.MyCommand.Path es el método clásico anterior a $PSScriptRoot. Otra alternativa es Split-Path -Parent $PSCommandPath: la documentación indica que $PSCommandPath contiene la ruta completa del script en ejecución y es válida en todos los scripts. Los tres dependen de que el código se ejecute como archivo, por eso el throw final es importante.

  2. Lanza el script como archivo desde el .bat. En un archivo por lotes, %~dp0 se expande a la unidad y la ruta de la carpeta donde está el propio .bat, con la barra final incluida. La documentación de powershell.exe recomienda justamente %~dp0 en lugar de .\ en scripts por lotes:

    @echo off
    powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0script.ps1"
    pause

    Notas sobre esas opciones: -File debe ser el último parámetro, porque todo lo que va después se interpreta como ruta del script y sus argumentos; -NoProfile evita cargar tu perfil; y -ExecutionPolicy Bypass aplica solo a esa sesión y, según la documentación, no cambia la política guardada en el registro. Úsalo únicamente con scripts que tú controlas.

  3. Añade una prueba que omita el parámetro. Con el cambio anterior, -ConfigPath sigue siendo opcional y tus pruebas existentes (que lo pasan) siguen siendo válidas, pero ya no bastan: la ruta por defecto es justo el camino que no cubrían.

Cómo verificar que quedó resuelto

Reproduce el escenario real: sin -ConfigPath y desde una carpeta de trabajo distinta a la del script, como hace el doble clic.

Set-Location $env:TEMP
powershell -NoProfile -ExecutionPolicy Bypass -File C:\proyectos\mi-app\script.ps1

Comprueba que el script termina sin error y que hace su trabajo (por ejemplo, que guarda el archivo esperado). Después haz doble clic en el .bat y repite la comprobación. En un caso real, tras mover la resolución de la ruta al cuerpo y lanzar el script con -File desde el .bat, el escenario sin -ConfigPath y desde otra carpeta de trabajo guardó el archivo sin error.

Para diagnosticar un caso dudoso, usa un script mínimo que muestre lo que PowerShell ve:

# diagnostico.ps1
"PSScriptRoot  : [$PSScriptRoot]"
"Ruta del script: [$($MyInvocation.MyCommand.Path)]"

Si ambos valores aparecen entre corchetes pero vacíos, el código no se está ejecutando como archivo y debes corregir el lanzador. Para comprobar que el mensaje claro funciona, puedes simular ese caso:

Invoke-Expression (Get-Content -Raw .\script.ps1)

Debe aparecer tu texto de throw en lugar del error de Join-Path.

Variantes y errores relacionados

"es nulo" en lugar de "cadena vacía"

Si el mensaje dice que el argumento "es nulo" (Cannot bind argument to parameter 'Path' because it is null), el valor no es una cadena vacía sino $null. Es lo que ocurre, por ejemplo, con Split-Path -Parent $MyInvocation.MyCommand.Path cuando esa propiedad es $null, algo que se observó al ejecutar el texto del script con Invoke-Expression. La causa de fondo es la misma: el script no se ejecuta como archivo.

Pegar el código en la consola

Al pegar el contenido de un script directamente en la consola, o ejecutar solo una selección en un editor, no hay archivo detrás, así que $PSScriptRoot no tiene a qué apuntar. Guarda el script y ejecútalo por su ruta.

Rutas relativas y la carpeta de trabajo

No uses .\config.json ni Get-Location para localizar archivos junto al script: dependen de desde dónde se lance. Con doble clic, la carpeta de trabajo puede no ser la del script. Resolver siempre desde la carpeta del script, como en el paso 1, evita ese problema.

Argumentos en el lanzador

Con -File, los argumentos que van detrás se pasan al script como cadenas literales. Si necesitas pasar una ruta con espacios, enciérrala entre comillas dentro del .bat.

Resumen

  • El error indica que $PSScriptRoot estaba vacío cuando se evaluó Join-Path; el valor por defecto de param() solo se calcula si omites el argumento, por eso las pruebas con el argumento explícito no lo detectan.
  • $PSScriptRoot es válido en todos los scripts desde PowerShell 3.0, pero queda vacío si el código no se ejecuta como archivo (Invoke-Expression, bloque de texto, entrada estándar).
  • Deja el parámetro sin valor calculado, resuelve la ruta en el cuerpo con $PSScriptRoot y, si hace falta, $MyInvocation.MyCommand.Path, y lanza un throw claro si ambos faltan.
  • Lanza el script desde el .bat con powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0script.ps1".
  • Verifica sin -ConfigPath y desde otra carpeta de trabajo, igual que lo hace el doble clic.

Referencias

Comentarios