shims/supabase.ps1

#Requires -Version 7
<#
    Production guard for the Supabase CLI.

    WHY THIS EXISTS AS A SHIM AND NOT AS A POWERSHELL FUNCTION

    The module already wraps `supabase`, but only through a PowerShell alias.
    An alias exists solely inside a PowerShell session that imported the
    module -- it covers neither git-bash, nor npm scripts, nor execFileSync
    from Node, nor an AI agent's shell. That is, none of the callers most
    likely to make the mistake. Sitting in the PATH is the only position that
    every caller passes through.

    The module itself already said so, in Sync-CtxSupabaseEnv, on 8 Aug 2026:
    "The PowerShell wrapper cannot intercept anything in that case: it is not
    in the call chain."

    CONTRACT

    Refuses only a case it is certain about. Every other path -- no context,
    no linked project, unknown environment, unreadable index, missing module,
    unexpected error -- delegates to the real CLI unchanged. A guard that
    breaks when it hesitates is a guard that gets uninstalled within the week.

    Exit code is the real CLI's, except on refusal, which exits 1.

    Console messages stay pure ASCII: an em-dash renders as '-' in git-bash and
    worse elsewhere, and a refusal message is the one output that must read
    correctly on a machine we know nothing about.

    No param() block on purpose: [CmdletBinding()] would swallow arguments
    like -debug or -verbose as its own parameters instead of forwarding them.
    $args forwards everything verbatim.
#>


Set-StrictMode -Off
$ErrorActionPreference = 'Stop'

$ShimDir = $PSScriptRoot
$Arguments = @($args)

# GARDE-FOU CONTRE L'AUTO-APPEL, ET POURQUOI IL NE COMPARE PAS DES CHEMINS.
#
# Resolve-RealExe s'ecarte lui-meme en comparant le dossier resolu a
# $PSScriptRoot. Cela suffit tant qu'un seul de nos dossiers figure dans PATH.
# Depuis le 15 aout 2026, PATH designe une JONCTION vers le module : le meme
# dossier porte alors deux noms, et si les deux se retrouvent dans PATH -- une
# migration interrompue, une installation manuelle, un PATH bricole -- chaque
# shim ecarte le sien, trouve l'autre, et l'appelle. Indefiniment.
#
# Un compteur ne ment pas, la ou un chemin ment volontiers : jonctions, casse,
# noms 8.3, lecteurs `subst`, chemins UNC.
#
# IL INTERROMPT LA BOUCLE, IL NE SAUTE JAMAIS LE CONTROLE. Une premiere version
# deleguait au binaire reel des la deuxieme entree -- ce qui donnait un
# contournement complet du garde-fou a qui posait DEVCTX_SHIM_DEPTH=1 avant sa
# commande. Une variable d'environnement qui desarme une protection doit etre
# documentee et volontaire (DEVCTX_ALLOW_PROD), jamais un effet de bord d'un
# mecanisme interne.
#
# Ici le garde-fou s'execute a chaque niveau ; le compteur ne fait qu'echouer
# franchement au lieu de tourner sans fin. Poser la variable a la main ne peut
# donc qu'interrompre plus tot : un refus, jamais un passe-droit.
#
# Message en anglais et code en dur : c'est un diagnostic de defaut
# d'installation, pas une sortie normale, et il doit s'afficher meme si le
# fichier de langue est ce qui manque.
$Profondeur = 0
if ($env:DEVCTX_SHIM_DEPTH) { $Profondeur = [int]$env:DEVCTX_SHIM_DEPTH }
if ($Profondeur -ge 3) {
    [Console]::Error.WriteLine('')
    [Console]::Error.WriteLine(' DevContext: shim loop detected -- a shim resolved to another shim.')
    [Console]::Error.WriteLine(' Two DevContext shim directories are probably both in PATH.')
    [Console]::Error.WriteLine(' Fix: pwsh -File installer-shims.ps1 -Verifier')
    [Console]::Error.WriteLine('')
    exit 1
}
$env:DEVCTX_SHIM_DEPTH = $Profondeur + 1

# --- delegation -------------------------------------------------------------

function Resolve-RealExe {
    # Deliberately duplicated from the module rather than imported: this must
    # still work when the module is missing or broken, which is exactly when
    # delegation matters most.
    $here = $ShimDir.TrimEnd('\', '/')
    Get-Command supabase -CommandType Application -All -ErrorAction SilentlyContinue |
        Where-Object { (Split-Path $_.Source -Parent).TrimEnd('\', '/') -ne $here } |
        Select-Object -First 1 -ExpandProperty Source
}

# La langue du shim.
#
# Le shim doit pouvoir REFUSER meme quand le module est absent ou casse -- c'est
# tout son contrat. Il source donc le fichier de langue directement, sans passer
# par le module, et se rabat sur une phrase anglaise codee en dur si meme cela
# echoue. Un refus muet serait pire qu'un refus mal traduit.
$Traduit = $false
try {
    . (Join-Path $PSScriptRoot '..' 'src' 'Langue.ps1')
    Set-CtxLangue | Out-Null
    $Traduit = $true
}
catch { $Traduit = $false }

function Dire {
    param([string]$Cle, [string]$Secours, [object[]]$Arguments)
    if (-not $Traduit) {
        if ($Arguments) { return ($Secours -f $Arguments) }
        return $Secours
    }
    if ($Arguments) { return (T $Cle @Arguments) }
    T $Cle
}

function Invoke-Real {
    $exe = Resolve-RealExe
    if (-not $exe) {
        Write-Error (Dire 'garde.introuvable' '{0} not found in PATH (outside the shims).' @('supabase'))
        exit 127
    }
    & $exe @Arguments
    exit $LASTEXITCODE
}

# --- gather -----------------------------------------------------------------

$verdict = $null
$projectName = $null

try {
    $module = Import-Module (Join-Path $PSScriptRoot '..' 'DevContext.psd1') -Force -PassThru -ErrorAction Stop

    # LE DOSSIER DECIDE, JAMAIS LA SESSION.
    #
    # Ce shim commencait par `if (-not $env:DEVCTX) { Invoke-Real }`, et
    # s'effacait donc des que la variable de session manquait : git-bash, script
    # npm, execFileSync depuis Node, shell d'un agent. C'est-a-dire partout ou
    # l'alias PowerShell ne va deja pas -- toute la raison d'etre du shim.
    #
    # Mesure le 15 aout 2026 : `supabase db reset --linked` sur une base de PRODUCTION est
    # passe depuis git-bash, arrete seulement par un timeout reseau.
    #
    # `work` reste utile (il charge le bon jeton) ; il n'est simplement plus ce
    # qui ARME la protection.
    # --workdir redirige la CLI vers un AUTRE projet. Le garde-fou doit juger la
    # cible reelle, pas le dossier depuis lequel on tape :
    # supabase --workdir F:\...\projet-de-prod db push
    # partait de n'importe ou et travaillait sur la production.
    $dossier = & $module { param($a) Get-CtxArgumentValeur -Arguments $a -Nom 'workdir' } $Arguments
    if (-not $dossier) { $dossier = $PWD.Path }

    # LE DOSSIER D'ABORD, la session seulement en secours.
    #
    # La version precedente lisait $env:DEVCTX en priorite et ne retombait sur le
    # dossier que si la variable etait vide. Quand session et dossier divergent
    # -- l'etat que `ctx` qualifie precisement de NO-GO -- elle interrogeait donc
    # l'index du MAUVAIS contexte, n'y trouvait pas le projet, et concluait
    # « pas de production ». Le commentaire disait « le dossier decide » ; le
    # code disait l'inverse. Releve par l'audit du 15 aout 2026.
    $contextes = @()
    $manifeste = & $module { param($p) Resolve-DevContextForPath -Path $p } $dossier
    if ($manifeste) { $contextes += & $module { param($m) Get-CtxProp $m 'name' } $manifeste }
    if ($env:DEVCTX -and $env:DEVCTX -notin $contextes) { $contextes += $env:DEVCTX }
    if ($contextes.Count -eq 0) { Invoke-Real }

    $ref = & $module { param($p) Resolve-CtxSupabaseRef -Path $p } $dossier
    if (-not $ref) { Invoke-Real }

    # Le verdict le plus restrictif l'emporte : si l'un des deux index connait ce
    # projet comme une production, c'en est une.
    $environment = $null
    $contexte    = $contextes[0]
    foreach ($c in $contextes) {
        $e = & $module { param($r, $n) Get-CtxSupabaseEnv -Ref $r -ContextName $n } $ref $c
        if ($e -eq 'prod') { $environment = 'prod'; $contexte = $c; break }
        if ($e -and -not $environment) { $environment = $e; $contexte = $c }
    }

    # L'index contient-il une production, quelque part ? Sert au seul cas ou le
    # garde-fou se ferme par defaut : un --db-url dont on ne sait pas lire la cible.
    $indexProd = $false
    foreach ($c in $contextes) {
        $p = & $module { param($n) Get-CtxSupabaseIndexPath $n } $c
        if ($p -and (Test-Path $p)) {
            $brut = Get-Content $p -Raw -ErrorAction SilentlyContinue
            if ($brut -match '"env"\s*:\s*"prod"') { $indexProd = $true; break }
        }
    }

    if ($environment -ne 'prod' -and -not $indexProd) { Invoke-Real }

    # Current branch. Absent outside a git repository, which is a pass.
    $currentBranch = git rev-parse --abbrev-ref HEAD 2>$null
    if ($LASTEXITCODE -ne 0 -or $currentBranch -eq 'HEAD') { $currentBranch = $null }

    # Default branch: what the remote declares, then the usual names, then
    # nothing -- and nothing means pass. We never block on a guess.
    $defaultBranch = git symbolic-ref --short refs/remotes/origin/HEAD 2>$null
    if ($LASTEXITCODE -eq 0 -and $defaultBranch) {
        # Retirer le prefixe du remote, et LUI SEUL. Un `-split '/'` suivi de
        # [-1] tronquait une branche hierarchique : avec
        # origin/HEAD -> origin/release/main, la branche par defaut devenait
        # 'main', et un developpeur sur une branche locale nommee 'main' voyait
        # son `db push` vers la production accepte.
        $defaultBranch = $defaultBranch -replace '^origin/', ''
    }
    else {
        $defaultBranch = $null
        foreach ($candidate in 'main', 'master') {
            git show-ref --verify --quiet "refs/heads/$candidate" 2>$null
            if ($LASTEXITCODE -eq 0) { $defaultBranch = $candidate; break }
        }
    }

    $verdict = & $module {
        param($a, $e, $c, $d, $o, $p)
        Test-CtxSupabaseGuard -Arguments $a -Environment $e `
            -CurrentBranch $c -DefaultBranch $d -Override:$o -IndexContientProd:$p
    } $Arguments $environment $currentBranch $defaultBranch ($env:DEVCTX_ALLOW_PROD -eq '1') $indexProd

    $projectName = & $module { param($r, $c)
        $p = Get-CtxSupabaseIndexPath $c
        if (Test-Path $p) {
            $i = Get-Content $p -Raw | ConvertFrom-Json
            $e = $i.PSObject.Properties | Where-Object { $_.Name -eq $r } | Select-Object -First 1
            if ($e) { Get-CtxProp $e.Value 'name' }
        }
    } $ref $contexte
}
catch {
    Invoke-Real
}

if (-not $verdict -or $verdict.Allowed) { Invoke-Real }

# --- refuse -----------------------------------------------------------------
#
# Nothing here prints an environment variable, a token, or the command's own
# arguments: a refusal message is written to logs and pasted into chats.

if (-not $projectName) { $projectName = Dire 'garde.nomInconnu' '(name unknown)' }

Write-Host ''
Write-Host " $(Dire 'garde.refuse' 'REFUSED - DevContext production guard')" -ForegroundColor Red
Write-Host ''
Write-Host " $(Dire 'garde.base' 'Target database : {0}' @($projectName))" -ForegroundColor Yellow
Write-Host " $(Dire 'garde.raison' 'Reason : {0}' @($verdict.Reason))"
Write-Host ''
Write-Host " $(Dire 'garde.derogation' 'If you really mean this command, for this one only:')" -ForegroundColor DarkGray
Write-Host ' $env:DEVCTX_ALLOW_PROD = 1' -ForegroundColor DarkGray
Write-Host " $(Dire 'garde.jamaisProfil1' 'Never put that in $PROFILE: it removes the guard while leaving')" -ForegroundColor DarkGray
Write-Host " $(Dire 'garde.jamaisProfil2' 'the impression of having it.')" -ForegroundColor DarkGray
Write-Host ''

exit 1