src/Vercel.ps1

# vercel -- meme dispositif que gh, sur une CLI qui n'offre pas la meme prise.
#
# CE QUI DIFFERE DE gh
#
# `vercel` n'a pas d'equivalent de GH_CONFIG_DIR : son dossier de configuration
# ne se choisit QUE par l'option `-Q, --global-config DIR`. L'isolation ne peut
# donc pas se poser dans l'environnement -- elle doit s'ecrire dans la ligne de
# commande, a chaque appel. C'est exactement ce que faisait deja
# Invoke-DevVercel, et qui n'existait que dans PowerShell.
#
# CE QUI EST GARDE, ET CE QUI NE L'EST PAS
#
# Deux refus seulement, et ils sont ceux de la feuille de route :
#
# deploiement `--prod` depuis une branche qui n'est pas la branche par defaut
# `env rm` visant explicitement l'environnement production
#
# Ne sont delibrement PAS gardes :
#
# `vercel rollback` -- c'est un geste de REPARATION. Le refuser tombe toujours
# au pire moment : pendant un incident, depuis une branche de correctif.
# `vercel promote` -- promouvoir un deploiement deja construit depuis une
# branche laterale est un correctif a chaud legitime.
# `vercel rm` -- ce qu'il supprime n'est pas identifiable comme production
# depuis la ligne de commande seule. Refuser sur un doute, c'est refuser au
# hasard.
#
# Cette liste courte est un choix. Un garde-fou dont on peut enoncer la regle en
# une phrase est un garde-fou qu'on garde ; SECURITY.md la reprend telle quelle.

function Get-CtxVercelMots {
    <#
      Les mots d'une ligne `vercel`, options ecartees. Rendus tous, et pas
      seulement les deux premiers : `vercel env rm CLE production` porte sa
      cible en quatrieme position.

      A ENVELOPPER DANS @() A L'APPEL. PowerShell deballe un tableau d'un seul
      element en le rendant, et `.Count` sur la chaine obtenue leve sous
      StrictMode. Mesure ici meme : `vercel build --prod` etait alors REFUSE,
      parce que la verification qui l'ecarte tombait avant d'avoir lieu. Un
      garde-fou qui se trompe de sens en cas d'erreur interne est pire qu'un
      garde-fou absent.
    #>

    param([string[]]$Arguments = @())
    @(
        $Arguments |
            Where-Object { $_ -and -not "$_".StartsWith('-') } |
            ForEach-Object { "$_".ToLowerInvariant() }
    )
}

function Test-CtxVercelGuard {
    <#
      DECISION PURE. Ni disque, ni reseau, ni git : tout arrive en parametre.

      Toute incertitude laisse passer. Une branche inconnue -- hors depot, HEAD
      detachee -- n'est pas une raison de bloquer un deploiement.
    #>

    [CmdletBinding()]
    param(
        [string[]]$Arguments = @(),
        [AllowNull()][string]$CurrentBranch,
        [AllowNull()][string]$DefaultBranch,
        [switch]$Override
    )

    $passe = { param($regle) [pscustomobject]@{ Allowed = $true; Rule = $regle; Reason = '' } }

    if ($Override) { return (& $passe 'derogation') }

    $mots = @(Get-CtxVercelMots -Arguments $Arguments)

    # --- env rm sur la production -------------------------------------------
    #
    # Seulement quand 'production' est ECRIT. Sans cible, la CLI ouvre une
    # invite et l'humain voit ce qu'il choisit ; refuser la ferait disparaitre
    # pour les environnements de developpement aussi, ou l'operation est banale.
    if ($mots.Count -ge 2 -and $mots[0] -eq 'env' -and $mots[1] -eq 'rm' -and 'production' -in $mots) {
        return [pscustomobject]@{
            Allowed = $false
            Rule    = 'env-rm-production'
            Reason  = (T 'vercel.raison.envRm')
        }
    }

    # --- deploiement de production hors branche par defaut -------------------
    $cible = Get-CtxArgumentValeur -Arguments $Arguments -Nom 'target'
    $prod  = ('--prod' -in @($Arguments | ForEach-Object { "$_" })) -or ($cible -eq 'production')
    if (-not $prod) { return (& $passe 'non-garde') }

    # `vercel build --prod` construit en local sans rien deployer : le refuser
    # serait un faux refus sur une commande sans effet distant. Un `vercel` nu
    # deploie, lui, et ne porte aucun mot.
    $commande = if ($mots.Count -ge 1) { $mots[0] } else { '' }
    if ($commande -and $commande -notin @('deploy', 'redeploy')) { return (& $passe 'non-deploiement') }

    if (-not $CurrentBranch -or -not $DefaultBranch) { return (& $passe 'branche-inconnue') }
    if ($CurrentBranch -eq $DefaultBranch)           { return (& $passe 'branche-par-defaut') }

    [pscustomobject]@{
        Allowed = $false
        Rule    = 'prod-hors-branche'
        Reason  = (T 'vercel.raison.prodBranche' $CurrentBranch $DefaultBranch)
    }
}

function Resolve-CtxVercelVerdict {
    <#
      RASSEMBLE, puis decide. Rend aussi le dossier de configuration a injecter,
      quand il y en a un a injecter.

      Meme lecon que le garde-fou Supabase le 16 aout 2026 : la regle vit dans
      le module, parce qu'elle a deux appelants -- le shim du PATH et l'alias
      PowerShell, ce dernier passant AVANT le shim dans une session qui a
      importe le module.
    #>

    param(
        [string[]]$Arguments = @(),
        [string]$Path
    )

    $dossier = if ($Path) { $Path } else { (Get-Location).Path }

    $branches = Get-CtxBranchesPour -Dossier $dossier
    $verdict  = Test-CtxVercelGuard -Arguments $Arguments `
        -CurrentBranch $branches.Courante -DefaultBranch $branches.Defaut `
        -Override:($env:DEVCTX_ALLOW_VERCEL -eq '1')

    $config = $null
    $avertissement = $null
    $manifeste = Resolve-DevContextForPath -Path $dossier
    if ($manifeste) {
        $contexte = Get-CtxProp $manifeste 'name'
        $attendu  = [System.IO.Path]::Combine((Get-CtxPath $contexte), 'vercel')
        $mots     = @(Get-CtxVercelMots -Arguments $Arguments)

        # Un `--global-config` deja present a ete ecrit volontairement. On ne
        # l'ecrase pas : un outil qui defait un choix explicite devient
        # imprevisible, et c'est la meme regle que pour GH_CONFIG_DIR.
        $dejaPose = @($Arguments | Where-Object {
                "$_" -eq '--global-config' -or "$_" -eq '-Q' -or "$_".StartsWith('--global-config=')
            }).Count -gt 0

        if (-not $dejaPose) {
            $connecte = Test-Path -LiteralPath ([System.IO.Path]::Combine($attendu, 'auth.json'))
            if ($mots.Count -ge 1 -and $mots[0] -in @('login', 'logout', 'switch')) {
                # Ces commandes ont le dossier de configuration pour SUJET. Les
                # rediriger fait qu'un `vercel login` tape dans le contexte
                # connecte CE contexte -- et c'est la sortie du cas "pas encore
                # de session", donc elle doit marcher avant qu'il y en ait une.
                if (-not (Test-Path -LiteralPath $attendu)) {
                    New-Item -ItemType Directory -Force -Path $attendu | Out-Null
                }
                $config = $attendu
                $avertissement = T 'vercel.avert.session' $contexte
            }
            elseif ($connecte) {
                $config = $attendu
            }
            # Ni session ni commande de session : on ne redirige pas. Pointer
            # vers un dossier vide rendrait "non connecte" la ou `vercel`
            # fonctionnait, ce qui est une regression, pas une protection.
        }
    }
    elseif ($env:DEVCTX_VERCEL_CONFIG) {
        # Hors de toute racine de contexte, mais un `work` est passe par la. On
        # honore ce qu'il a pose : c'est le comportement d'Invoke-DevVercel
        # depuis toujours, et le retirer casserait les dossiers qui vivent en
        # dehors des racines declarees.
        $sessionPosee = [System.IO.Path]::Combine($env:DEVCTX_VERCEL_CONFIG, 'auth.json')
        if (Test-Path -LiteralPath $sessionPosee) { $config = $env:DEVCTX_VERCEL_CONFIG }
    }

    [pscustomobject]@{ Verdict = $verdict; ConfigDir = $config; Avertissement = $avertissement }
}

function Get-CtxVercelExe {
    <#
      Le VRAI binaire vercel, en ecartant tous nos dossiers de shims.

      Indispensable depuis qu'un shim `vercel` existe : sans cette exclusion,
      `Get-Command vercel` trouve le shim, qui se rappelle lui-meme. Le module
      a paye cette lecon trois fois -- Get-CtxSupabaseExe (1.3.1),
      Find-CtxEditorCli (1.3.2), l'audit des raccourcis (1.3.4) -- et la
      comparaison porte donc sur TOUS les noms du dossier, jamais un seul.
    #>

    $exclus = Get-CtxShimDirs
    $tous = @(Get-Command vercel -CommandType Application, ExternalScript -ErrorAction SilentlyContinue)
    $candidat = $tous | Where-Object {
        -not (Test-CtxDossierEstShimDevContext -Dossier (Split-Path $_.Source -Parent) -Dossiers $exclus)
    } | Select-Object -First 1

    if (-not $candidat) { throw (T 'bin.vercelAbsent') }
    $candidat.Source
}

function Write-CtxVercelRefus {
    <#
      N'imprime ni jeton, ni variable d'environnement, ni les arguments : un
      refus finit dans les journaux et se colle dans les conversations.
    #>

    param([Parameter(Mandatory)]$Verdict)

    Write-Host ''
    Write-Host " $(T 'vercel.refuse')" -ForegroundColor Red
    Write-Host ''
    Write-Host " $(T 'garde.raison' $Verdict.Reason)"
    Write-Host ''
    Write-Host " $(T 'vercel.derogation')" -ForegroundColor DarkGray
    Write-Host ' $env:DEVCTX_ALLOW_VERCEL = 1' -ForegroundColor DarkGray
    Write-Host " $(T 'garde.jamaisProfil1')" -ForegroundColor DarkGray
    Write-Host " $(T 'garde.jamaisProfil2')" -ForegroundColor DarkGray
    Write-Host ''
}