installer-shims.ps1
|
#Requires -Version 7 <# .SYNOPSIS Puts the DevContext shim folder at the FRONT of the user PATH. .DESCRIPTION The shims decide from the FOLDER, never from the session. That is what makes them work where an alias cannot: git-bash, npm scripts, Node's execFileSync, an AI agent's shell. The only handover point common to all of them is PATH. User scope only -- no administrator rights, fully reversible. Like the module itself, the shims are used from the repository, never copied. That makes PATH a fourth external consumer pointing at this folder by absolute path. Moving the repository breaks it silently, exactly as it broke the shortcuts and the registry key on 13 Aug 2026. See INSTALLATION.md. .PARAMETER Verifier Reports the current state. Touches nothing. .PARAMETER Restaurer Removes the shim folder from the user PATH. .PARAMETER AsLibrary Loads the functions without running anything, so tests can exercise the pure PATH logic without installing on the machine running them. .EXAMPLE pwsh -NoProfile -File .\installer-shims.ps1 -Verifier #> [CmdletBinding()] param( [switch]$Verifier, [switch]$Restaurer, [switch]$AsLibrary ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # T est INTERNE au module : un script autonome doit sourcer le fichier de langue # lui-meme. L'oublier n'a pas de symptome visible autre que des messages du type # « [inst.pose] » -- et c'est exactement pourquoi une cle manquante s'affiche au # lieu de disparaitre. . (Join-Path $PSScriptRoot 'src' 'Langue.ps1') Set-CtxLangue | Out-Null # Meme fichier que celui charge par le module : les regles de chemin ne doivent # exister qu'une fois. Deux exemplaires d'une meme regle, c'est l'incident du # 12 aout 2026 -- une correction appliquee a la copie qui n'etait pas executee. . (Join-Path $PSScriptRoot 'src' 'Chemins.ps1') # Le dossier REEL, celui ou vivent et s'ecrivent les fichiers. $script:ShimDir = Join-Path $PSScriptRoot 'shims' # Le chemin pose dans PATH : une jonction, sans numero de version. Voir # src/Chemins.ps1 pour ce que cette distinction repare. $script:Jonction = Get-CtxShimLien $script:CheminStable = Get-CtxShimStable $script:ShimFichiers = @( 'supabase.ps1', 'supabase.cmd', 'supabase' 'gh.ps1', 'gh.cmd', 'gh' 'vercel.ps1', 'vercel.cmd', 'vercel' 'editor.ps1' ) # Les CLI enrobees par un shim COMMIS -- par opposition aux editeurs, dont les # points d'entree sont generes d'apres ce qui est installe sur la machine. # Sert au rapport de `-Verifier` : montrer la resolution reelle de chacune est # le seul moyen de voir qu'un shim est bien devant le vrai binaire. $script:ShimOutils = @('supabase', 'gh', 'vercel') # Written into every generated entry point, and the ONLY thing that authorises # deleting one. An installer that removes files by name pattern eventually # removes a file somebody else put there. $script:MarqueGeneree = 'GENERE PAR DEVCONTEXT' # --------------------------------------------------------------------------- # Pure PATH logic -- no registry, no side effect, therefore testable # --------------------------------------------------------------------------- function Compare-CtxPathEntry { <# Windows resolves PATH without regard to case, and treats a trailing backslash as noise. Comparing raw strings would happily install a second copy of an entry that is already there. #> param([string]$A, [string]$B) $A.Trim().TrimEnd('\', '/').ToLowerInvariant() -eq $B.Trim().TrimEnd('\', '/').ToLowerInvariant() } function Add-CtxPathEntry { <# Returns the new PATH, or nothing at all when the entry is already there. "Nothing" is the signal for "no write needed" -- the caller must not write a value it did not get. Every other entry is copied through verbatim, empty ones included. An empty entry means "the current directory" on Windows, which is a fair thing to raise with the user, but not something an installer decides on their behalf. #> param( [Parameter(Mandatory)][string]$Entry, [Parameter(Mandatory)][AllowEmptyString()][string]$Current ) $entries = $Current -split ';' if ($entries | Where-Object { $_ -and (Compare-CtxPathEntry $_ $Entry) }) { return } if (-not $Current) { return $Entry } (@($Entry) + $entries) -join ';' } function Remove-CtxPathEntry { <# Removes every copy of the entry, and only that entry. Returns nothing when there was nothing to remove. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Fonction pure : rend la nouvelle chaine PATH, n ecrit rien. Le registre est ecrit par Set-CtxUserPath.')] param( [Parameter(Mandatory)][string]$Entry, [Parameter(Mandatory)][AllowEmptyString()][string]$Current ) $entries = $Current -split ';' $kept = @($entries | Where-Object { -not ($_ -and (Compare-CtxPathEntry $_ $Entry)) }) if ($kept.Count -eq $entries.Count) { return } $kept -join ';' } # --------------------------------------------------------------------------- # Registry access -- raw, and preserving the value kind # --------------------------------------------------------------------------- function Get-CtxUserPath { <# Reads HKCU\Environment\Path WITHOUT expanding variables, and reports the registry kind alongside. Why not [Environment]::GetEnvironmentVariable('Path','User'): it returns the EXPANDED value. Writing that back bakes %USERPROFILE% in as a literal path and downgrades REG_EXPAND_SZ to REG_SZ -- permanently, and silently. The author's own PATH happens to be REG_SZ with no variables in it, so the damage would only have shown up on someone else's machine. -Cle exists so the tests can prove that property against a scratch key, instead of skipping on any machine whose PATH happens to hold no variable. #> param([string]$Cle = 'HKCU:\Environment') if (-not (Test-Path -LiteralPath $Cle)) { return [pscustomobject]@{ Value = ''; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString } } $item = Get-Item -LiteralPath $Cle if ('Path' -notin $item.GetValueNames()) { return [pscustomobject]@{ Value = ''; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString } } [pscustomobject]@{ Value = [string]$item.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) Kind = $item.GetValueKind('Path') } } function Set-CtxUserPath { # Ecriture reelle dans HKCU\Environment. -WhatIf doit pouvoir la retenir. [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Value, [Parameter(Mandatory)][Microsoft.Win32.RegistryValueKind]$Kind, [string]$Cle = 'HKCU:\Environment' ) if (-not $PSCmdlet.ShouldProcess($Cle, 'reecrire la valeur Path')) { return } Set-ItemProperty -LiteralPath $Cle -Name 'Path' -Value $Value -Type $Kind if ($Cle -eq 'HKCU:\Environment') { Publish-CtxEnvironmentChange } } function Publish-CtxEnvironmentChange { <# Writing the registry directly does not tell anybody. [Environment]:: SetEnvironmentVariable broadcasts WM_SETTINGCHANGE for you; since we deliberately bypass it to preserve the value kind, we broadcast ourselves. Best effort: if the interop type cannot be built, the PATH is still correct on disk and a new terminal will pick it up. We say so out loud rather than let the user believe something happened that did not. #> $signature = @' [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout( IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); '@ try { if (-not ('DevContext.NativeMethods' -as [type])) { Add-Type -Namespace 'DevContext' -Name 'NativeMethods' -MemberDefinition $signature } $resultat = [UIntPtr]::Zero # HWND_BROADCAST = 0xffff, WM_SETTINGCHANGE = 0x1A, SMTO_ABORTIFHUNG = 2 [void][DevContext.NativeMethods]::SendMessageTimeout( [IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$resultat) } catch { Write-Warning (T 'inst.broadcast' $_.Exception.Message) Write-Warning (T 'inst.broadcastSuite') } } # --------------------------------------------------------------------------- # Editor entry points -- generated, because the list is per machine # --------------------------------------------------------------------------- # # The supabase guard ships as three committed files: there is exactly one # supabase. Editors are the opposite. This machine carries VS Code, Cursor, # Windsurf, Antigravity and Trae; the next one carries Positron and VSCodium and # a build of Cursor from a path nobody guessed. Committing an entry point for # every name we can think of would shadow commands that are not installed -- # typing `cursor` on a machine without Cursor would answer with a DevContext # error instead of "command not found", which is worse than doing nothing. # # So: entry points are written for the editors actually FOUND, and removed when # an editor goes away. shims/.gitignore keeps them out of the repository. function New-CtxEntryPointContent { <# PURE. The two entry-point bodies for one editor name. The editor's name travels in an environment variable, never as a script parameter: the argument stream belongs to the caller and reaches the editor untouched. `code --wait COMMIT_EDITMSG` is git's editor. `setlocal` matters. Without it, `set DEVCTX_SHIM_EDITOR` leaks into the calling cmd.exe session, and the next shim invoked from that session would inherit the wrong editor name. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Fonction pure : rend le contenu des points d entree, ne cree aucun fichier.')] param([Parameter(Mandatory)][string]$Nom) $cmd = @( '@echo off' "rem $($script:MarqueGeneree) -- ne pas editer, relancer installer-shims.ps1" 'setlocal' "set `"DEVCTX_SHIM_EDITOR=$Nom`"" 'pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0editor.ps1" %*' 'exit /b %ERRORLEVEL%' ) -join "`r`n" $posix = @( '#!/bin/sh' "# $($script:MarqueGeneree) -- ne pas editer, relancer installer-shims.ps1" 'DIR=$(dirname "$0")' "DEVCTX_SHIM_EDITOR=$Nom exec pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"`$DIR/editor.ps1`" `"`$@`"" ) -join "`n" [pscustomobject]@{ Cmd = $cmd + "`r`n"; Posix = $posix + "`n" } } function Get-CtxEntryPointsExistants { <# Entry points we generated, and only those. Identified by their marker, not by their name: a file we did not write is not ours to delete. #> param([string]$Dossier = $script:ShimDir) Get-ChildItem -LiteralPath $Dossier -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notin $script:ShimFichiers } | Where-Object { $tete = Get-Content -LiteralPath $_.FullName -TotalCount 3 -ErrorAction SilentlyContinue $tete -and ($tete -join "`n").Contains($script:MarqueGeneree) } } function Sync-CtxEditorEntryPoints { <# Brings the entry points in line with the editors present on this machine. Reports what it did rather than printing: the caller decides how to show it, and a test can assert on the result instead of scraping the console. #> [CmdletBinding(SupportsShouldProcess)] param( # AllowEmptyCollection, et ce n'est pas une commodite : `-Restaurer` # appelle avec @() pour tout retirer. Sans cela, la desinstallation # levait sur la liaison de parametre et laissait les points d entree # derriere elle, tout en retirant le PATH. [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Noms, [string]$Dossier = $script:ShimDir ) $ecrits = @() $retires = @() foreach ($nom in $Noms) { $contenu = New-CtxEntryPointContent -Nom $nom $paires = @( @{ Chemin = (Join-Path $Dossier "$nom.cmd"); Valeur = $contenu.Cmd } @{ Chemin = (Join-Path $Dossier $nom); Valeur = $contenu.Posix } ) foreach ($p in $paires) { if ($PSCmdlet.ShouldProcess($p.Chemin, 'ecrire le point d entree')) { # -NoNewline : les fins de ligne sont dans le contenu, et elles # sont porteuses. Un `#!/bin/sh` termine en CRLF echoue sur # « bad interpreter: /bin/sh^M », une erreur qui nomme # l'interpreteur plutot que la cause. Set-Content -LiteralPath $p.Chemin -Value $p.Valeur -Encoding ASCII -NoNewline $ecrits += $p.Chemin } } } $attendus = @($Noms | ForEach-Object { $_; "$_.cmd" }) foreach ($f in Get-CtxEntryPointsExistants -Dossier $Dossier) { if ($f.Name -in $attendus) { continue } if ($PSCmdlet.ShouldProcess($f.FullName, 'retirer un point d entree devenu inutile')) { Remove-Item -LiteralPath $f.FullName -Force $retires += $f.FullName } } [pscustomobject]@{ Ecrits = $ecrits; Retires = $retires } } function Test-CtxShimComplet { <# Three entry points, three callers. Installing a partial set would put a hole in the guard exactly where the missing shell is. #> $manquants = @($script:ShimFichiers | Where-Object { -not (Test-Path -LiteralPath (Join-Path $script:ShimDir $_)) }) if ($manquants.Count) { throw (T 'inst.shimsManquants' ($manquants -join ', ')) } } function Get-CtxEditeursEnrobables { <# The editors worth generating an entry point for. Two conditions, both necessary. A command-line entry point, or the shim resolves to nothing and answers 127 where the bare name used to work -- Antigravity is installed on this machine and has none. And a profile flag that was established, or the entry point costs a process launch to add nothing. #> try { Import-Module (Join-Path $PSScriptRoot 'DevContext.psd1') -Force -ErrorAction Stop } catch { Write-Warning (T 'inst.moduleIllisible' $_.Exception.Message) return @() } & (Get-Module DevContext) { foreach ($e in Get-CtxEditorFacts) { if (-not $e.Cli) { continue } $c = Get-CtxEditorCapabilitiesCached -Editor $e if (-not $c.UserDataDir) { continue } [pscustomobject]@{ Nom = $e.Name; Libelle = $e.Label; Methode = $c.Method; Extensions = $c.ExtensionsDir } } } } if ($AsLibrary) { return } # --------------------------------------------------------------------------- # Modes # --------------------------------------------------------------------------- $etat = Get-CtxUserPath $pose = -not (Add-CtxPathEntry -Entry $script:CheminStable -Current $etat.Value) # L'ancienne forme : le dossier du module directement dans PATH. Presente sur # toute machine installee avant le 15 aout 2026. On la detecte pour la retirer, # et non pour la tolerer : deux de nos dossiers dans PATH, c'est la boucle que # les shims interrompent desormais par compteur. $ancienPose = -not (Add-CtxPathEntry -Entry $script:ShimDir -Current $etat.Value) if ($Verifier) { Write-Host '' if ($pose) { Write-Host " $(T 'inst.actif')" -ForegroundColor Green } else { Write-Host " $(T 'inst.absent')" -ForegroundColor Yellow } Write-Host " $(T 'inst.dossier' $script:CheminStable)" Write-Host " $(T 'inst.registre' $etat.Kind)" Write-Host '' $cible = Get-CtxJonctionCible -Chemin $script:Jonction Write-Host " $(T 'inst.jonction')" if (-not $cible) { Write-Host " $(T 'inst.jonctionAbsente')" -ForegroundColor Yellow } elseif (Test-CtxJonctionSaine -Cible $cible -ModuleAttendu $PSScriptRoot) { Write-Host " $cible" -ForegroundColor DarkGray } else { Write-Host " $cible" -ForegroundColor Yellow Write-Host " $(T 'inst.jonctionAilleurs' $PSScriptRoot)" -ForegroundColor Yellow } if ($ancienPose) { Write-Host '' Write-Host " $(T 'inst.ancienneEntree' $script:ShimDir)" -ForegroundColor Yellow } Write-Host '' Write-Host " $(T 'inst.fichiers')" foreach ($f in $script:ShimFichiers) { $p = Join-Path $script:ShimDir $f $ok = Test-Path -LiteralPath $p Write-Host (' {0,-14} {1}' -f $f, $(if ($ok) { 'present' } else { 'MANQUANT' })) ` -ForegroundColor $(if ($ok) { 'DarkGray' } else { 'Red' }) } Write-Host '' Write-Host " $(T 'inst.pointsEntree')" $generes = @(Get-CtxEntryPointsExistants) if (-not $generes) { Write-Host " $(T 'inst.aucun')" -ForegroundColor DarkGray } foreach ($g in $generes) { Write-Host " $($g.Name)" -ForegroundColor DarkGray } Write-Host '' Write-Host " $(T 'inst.resolution')" $nosDossiers = @($script:CheminStable, $script:ShimDir) # Chaque outil separement, et non le seul `supabase` : un shim peut etre # devant pour l'un et derriere pour l'autre -- npm reinstalle `vercel` et le # place ailleurs dans PATH, par exemple. Un rapport qui n'en montre qu'un # laisserait croire que la reponse vaut pour les trois. $unDerriere = $false foreach ($outil in $script:ShimOutils) { Write-Host " $outil" -ForegroundColor Cyan $resolus = @(Get-Command $outil -CommandType Application, ExternalScript -All -ErrorAction SilentlyContinue) if (-not $resolus) { Write-Host " $(T 'inst.aucune')" -ForegroundColor Yellow continue } foreach ($r in $resolus) { $notre = Test-CtxDossierEstShimDevContext -Dossier (Split-Path $r.Source -Parent) -Dossiers $nosDossiers $marque = if ($notre) { '->' } else { ' ' } $couleur = if ($notre) { 'Green' } else { 'DarkGray' } Write-Host (' {0} {1}' -f $marque, $r.Source) -ForegroundColor $couleur } if (-not (Test-CtxDossierEstShimDevContext -Dossier (Split-Path $resolus[0].Source -Parent) -Dossiers $nosDossiers)) { $unDerriere = $true } } Write-Host '' if ($pose -and $unDerriere) { Write-Host " $(T 'inst.poseNonActif')" -ForegroundColor Yellow Write-Host " $(T 'inst.terminalNeuf')" -ForegroundColor Yellow Write-Host '' } return } if ($Restaurer) { $retrait = Sync-CtxEditorEntryPoints -Noms @() foreach ($r in $retrait.Retires) { Write-Host " $(T 'inst.retire' (Split-Path $r -Leaf))" -ForegroundColor DarkGray } # Les DEUX formes, la stable et l'ancienne. Ne retirer que celle qu'on pose # aujourd'hui laisserait derriere elle l'entree des installations d'avant le # 15 aout 2026 : une desinstallation qui ne desinstalle pas tout. $courant = $etat.Value foreach ($entree in @($script:CheminStable, $script:ShimDir)) { $reduit = Remove-CtxPathEntry -Entry $entree -Current $courant if ($null -ne $reduit) { $courant = $reduit } } $jonctionRetiree = Remove-CtxJonction -Chemin $script:Jonction if ($jonctionRetiree) { Write-Host " $(T 'inst.jonctionRetiree')" -ForegroundColor DarkGray } if ($courant -eq $etat.Value) { Write-Host " $(T 'inst.dejaAbsent')" -ForegroundColor DarkGray return } Set-CtxUserPath -Value $courant -Kind $etat.Kind Write-Host " $(T 'inst.retireDuPath')" -ForegroundColor Green Write-Host " $(T 'inst.ancienPath')" -ForegroundColor DarkGray return } Test-CtxShimComplet # Les editeurs d'abord : la pose du PATH peut n'avoir rien a faire et sortir # tot, alors qu'un editeur a pu etre installe ou desinstalle depuis. $editeurs = @(Get-CtxEditeursEnrobables) $sync = Sync-CtxEditorEntryPoints -Noms @($editeurs | ForEach-Object Nom) Write-Host '' if ($editeurs) { Write-Host " $(T 'inst.editeursEnrobes')" -ForegroundColor Green foreach ($e in $editeurs) { $ext = if ($e.Extensions) { T 'inst.profilEtExt' } else { T 'inst.profilSeul' } Write-Host (' {0,-22} {1,-20} ({2})' -f $e.Libelle, $ext, $e.Methode) -ForegroundColor DarkGray } } else { Write-Host " $(T 'inst.aucunEditeur')" -ForegroundColor Yellow Write-Host " $(T 'inst.aucunEditeurFix')" -ForegroundColor DarkGray } foreach ($r in $sync.Retires) { Write-Host " $(T 'inst.retire' (Split-Path $r -Leaf))" -ForegroundColor DarkGray } # La jonction AVANT le PATH. Poser dans PATH un chemin qui ne mene encore nulle # part donnerait un `supabase` introuvable jusqu'au prochain terminal -- une # fenetre pendant laquelle le garde-fou est annonce sans etre la. Set-CtxJonction -Chemin $script:Jonction -Cible $PSScriptRoot | Out-Null Write-Host '' Write-Host " $(T 'inst.jonctionPosee')" -ForegroundColor Green Write-Host " $script:Jonction" Write-Host " -> $PSScriptRoot" -ForegroundColor DarkGray $courant = $etat.Value # Migration : retirer l'ancienne entree, celle qui portait le dossier du module. # Sur une installation Gallery elle porte un numero de version et se perime a la # mise a jour suivante ; sur toute installation elle ferait un second dossier de # shims dans PATH, donc une boucle potentielle. $sansAncien = Remove-CtxPathEntry -Entry $script:ShimDir -Current $courant if ($null -ne $sansAncien) { $courant = $sansAncien Write-Host " $(T 'inst.ancienneRetiree' $script:ShimDir)" -ForegroundColor DarkGray } $nouveau = Add-CtxPathEntry -Entry $script:CheminStable -Current $courant if (-not $nouveau) { $nouveau = $courant } if ($nouveau -eq $etat.Value) { Write-Host '' Write-Host " $(T 'inst.pathDejaPose')" -ForegroundColor DarkGray return } $sauvegarde = Join-Path $env:LOCALAPPDATA 'DevContext\path-utilisateur-avant-shims.txt' New-Item -ItemType Directory -Path (Split-Path $sauvegarde) -Force | Out-Null Set-Content -LiteralPath $sauvegarde -Value $etat.Value -Encoding UTF8 -NoNewline Set-CtxUserPath -Value $nouveau -Kind $etat.Kind Write-Host '' Write-Host " $(T 'inst.pose')" -ForegroundColor Green Write-Host " $script:CheminStable" Write-Host " $(T 'inst.typePreserve' $etat.Kind)" Write-Host " $(T 'inst.sauvegarde' $sauvegarde)" -ForegroundColor DarkGray if ($nouveau.Length -gt 2000) { Write-Warning (T 'inst.pathLong' $nouveau.Length) } Write-Host " $(T 'inst.ancienPathNeuf')" -ForegroundColor Yellow Write-Host '' |