src/Init.ps1

# ---------------------------------------------------------------------------
# ctx init -- la premiere commande, celle ou l'adoption se gagne ou se perd
# ---------------------------------------------------------------------------
#
# THE PROBLEM
#
# Until 1.8.0, a new user had to clone the repository, create a symlink, run an
# installer, then create contexts by hand from a command line with five
# parameters. Every one of those steps is a place to stop.
#
# Worse, the failure modes were silent. A virgin machine was simulated on 15
# August 2026 and the CLI walked into five dead ends in a row -- `ctx` answered
# a red NO-GO to someone who had done nothing wrong, `ctx-list` said "none"
# without saying what to do next, and two prompts hung forever on redirected
# input.
#
# WHAT THIS COMMAND IS, AND IS NOT
#
# It is a GUIDE, not a wizard that takes over. It reports what is in place, asks
# before each change, and every step it does not perform is printed as the exact
# command that performs it. Someone who prefers to drive by hand loses nothing
# by running it.
#
# It is IDEMPOTENT. Running it on a fully configured machine changes nothing and
# says so -- because the second most likely moment to run it is "something is
# off and I do not remember what I did".
#
# AND IT REFUSES TO ASK A QUESTION IT CANNOT HEAR THE ANSWER TO
#
# With redirected input -- an agent, a CI job, a script -- a prompt does not
# pause, it reads EOF and either loops or takes a default nobody chose. This
# module already paid that lesson: `ctx-new` hung on a passphrase prompt on a
# machine that could never answer, which is why -NoKey exists.
#
# So the interactivity is DETECTED, not assumed, and the non-interactive path is
# a full citizen: it prints the ordered list of commands and exits. Not a
# degraded mode -- the same information, in a form a script can act on.

function Invoke-DevContextInit {
    <#
    .SYNOPSIS
        Sets this machine up, one confirmed step at a time.

    .DESCRIPTION
        Reports what is already in place, then walks the missing pieces in
        order, asking before each change. Every step it does not perform is
        printed as the exact command that performs it -- so someone who prefers
        to drive by hand loses nothing by running this first.

        Idempotent: on a machine that is already set up it changes nothing and
        says so.

        With redirected input it does not prompt at all. It prints the ordered
        list of commands and exits, because a prompt nobody can answer is worse
        than no prompt.

    .PARAMETER Yes
        Answers yes to every confirmation. For a script that genuinely means it.

    .EXAMPLE
        ctx init
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [switch]$Yes,
        # Injectables : les deux chemins doivent etre testables sans rediriger
        # l'entree du processus de test, et sans installer quoi que ce soit.
        [scriptblock]$SondeInteractive,
        $Faits
    )

    if (-not $Faits) { $Faits = Get-CtxInitFacts }
    $etapes = @(Resolve-CtxInitEtapes -Faits $Faits)
    $manquantes = @($etapes | Where-Object { -not $_.Fait })

    $peutDemander = if ($SondeInteractive) { Test-CtxPeutDemander -Sonde $SondeInteractive }
    else { Test-CtxPeutDemander }

    Write-Host ''
    Write-Host " $(T 'init.titre')" -ForegroundColor Cyan
    Write-Host ''

    # L'etat, toujours affiche -- y compris ce qui va bien. C'est ce qui permet
    # de lancer cette commande quand "quelque chose cloche" sans savoir quoi.
    foreach ($e in $etapes) {
        $marque = if ($e.Fait) { '+' } else { '-' }
        $couleur = if ($e.Fait) { 'Green' } else { 'Yellow' }
        Write-Host (" {0} {1}" -f $marque, (T "init.etape.$($e.Cle)")) -ForegroundColor $couleur
    }
    Write-Host ''
    Write-Host " $(T 'init.racine' (Get-CtxProp $Faits 'Racine'))" -ForegroundColor DarkGray
    Write-Host " $(T 'init.racineChanger')" -ForegroundColor DarkGray
    Write-Host ''

    if ($manquantes.Count -eq 0) {
        Write-Host " $(T 'init.rienAFaire')" -ForegroundColor Green
        Write-Host " $(T 'init.suite')" -ForegroundColor DarkGray
        Write-Host ''
        return
    }

    # --- entree redirigee : on n'invente pas de reponse -----------------------
    if (-not $peutDemander -and -not $Yes) {
        Write-Host " $(T 'init.nonInteractif')" -ForegroundColor Yellow
        Write-Host ''
        foreach ($e in $manquantes) {
            Write-Host (" {0}" -f (T "init.etape.$($e.Cle)")) -ForegroundColor Yellow
            Write-Host (" {0}" -f $e.Commande) -ForegroundColor Cyan
        }
        Write-Host ''
        Write-Host " $(T 'init.suite')" -ForegroundColor DarkGray
        Write-Host ''
        return
    }

    # --- chemin interactif ----------------------------------------------------
    foreach ($e in $manquantes) {
        Write-Host (" {0}" -f (T "init.etape.$($e.Cle)")) -ForegroundColor Yellow

        if (-not $e.Auto) {
            # Rien a proposer d'executer : on nomme la commande et on passe.
            # Installer un module est une dependance nouvelle, et cela ne se
            # decide pas au nom de quelqu'un d'autre.
            Write-Host (" {0}" -f (T 'init.manuel')) -ForegroundColor DarkGray
            Write-Host (" {0}" -f $e.Commande) -ForegroundColor Cyan
            Write-Host ''
            continue
        }

        if (-not ($Yes -or $PSCmdlet.ShouldProcess($e.Cle, (T 'init.action')))) {
            Write-Host (" {0}" -f $e.Commande) -ForegroundColor Cyan
            Write-Host ''
            continue
        }

        switch ($e.Cle) {
            'shims' {
                $r = Repair-CtxShims -Confirm:$false
                $couleur = if (Get-CtxProp $r 'Applique') { 'Green' } else { 'Red' }
                Write-Host (" {0}" -f (Get-CtxProp $r 'Detail')) -ForegroundColor $couleur
            }
            'contexte' {
                # On ne cree RIEN sans les informations exactes : la commande est
                # affichee pre-remplie, et c'est l'utilisateur qui la lance. Creer
                # un contexte pose une cle SSH et un dossier ; le faire sur une
                # supposition serait le genre d'aide dont on se passe.
                Write-Host (" {0}" -f (T 'init.contexteProposition')) -ForegroundColor DarkGray
                Write-Host (" {0}" -f $e.Commande) -ForegroundColor Cyan
            }
        }
        Write-Host ''
    }

    Write-Host " $(T 'init.suite')" -ForegroundColor DarkGray
    Write-Host ''
}

function Get-CtxGhComptesDepuisStatut {
    <#
      PURE. Les logins presents dans la sortie de `gh auth status`.

      Format reel, mesure le 17 aout 2026 :

          github.com
            v Logged in to github.com account octo-dev (keyring)
            - Active account: true

      On lit la ligne "Logged in to <hote> account <login>", et rien d'autre :
      les lignes voisines contiennent un jeton masque et des portees, dont ce
      code n'a aucun besoin.

      FAIL-SOFT PAR CONSTRUCTION. Ce resultat ne sert qu'a PRE-REMPLIR une
      proposition. Si gh change son libelle, la liste est vide, la proposition
      est moins bonne, et rien ne casse. Une detection cosmetique n'a pas le
      droit d'empecher la commande de fonctionner.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string]$Texte)

    if ([string]::IsNullOrWhiteSpace($Texte)) { return @() }
    $trouves = [regex]::Matches($Texte, '(?im)^\s*.{0,3}\s*Logged in to \S+ account (\S+)')
    @($trouves | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ } | Select-Object -Unique)
}

function Resolve-CtxNomPropose {
    <#
      PURE. Un nom de contexte valide, propose a partir de ce qu'on sait deja.

      New-DevContext exige ^[a-z0-9][a-z0-9-]*$. Proposer un nom qu'il refusera
      ensuite est la pire des aides : l'utilisateur croit avoir suivi la
      consigne et se prend un rejet.

      Ordre de preference : le login GitHub, puis la partie locale de l'email,
      puis 'perso'. Le login d'abord parce que c'est le nom sous lequel le
      travail sortira reellement.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyString()][string]$Login,
        [AllowNull()][AllowEmptyString()][string]$Email
    )

    foreach ($source in @($Login, ($Email -split '@')[0])) {
        if ([string]::IsNullOrWhiteSpace($source)) { continue }
        # Minuscules, et tout ce qui n'est ni lettre ni chiffre devient un tiret.
        $n = ($source.Trim().ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-')
        if ($n -match '^[a-z0-9][a-z0-9-]*$') { return $n }
    }
    'perso'
}

function Get-CtxInitFacts {
    <#
      GATHERING. Ce qui est deja en place sur cette machine.

      Aucun jugement ici : la question "que faut-il faire" est repondue par
      Resolve-CtxInitEtapes, qui est pure et donc verifiable sans machine.

      Chaque source est protegee par un try : `ctx init` s'adresse par
      definition a une machine mal configuree, et il serait absurde qu'il tombe
      sur la premiere chose qui manque.
    #>

    [CmdletBinding()]
    param(
        [scriptblock]$LecteurGh = {
            $exe = try { Get-CtxGhExe } catch { $null }
            if (-not $exe) { return '' }
            try { (& $exe auth status 2>&1 | Out-String) } catch { '' }
        },
        [scriptblock]$LecteurGit = {
            # try/catch est une INSTRUCTION, pas une expression : il ne peut pas
            # vivre dans un litteral de table de hachage. D'ou les variables
            # intermediaires ici et plus bas -- ce n'est pas de la verbosite.
            $n = $null; $e = $null
            try { $n = git config --global user.name 2>$null } catch { $n = $null }
            try { $e = git config --global user.email 2>$null } catch { $e = $null }
            [pscustomobject]@{ Nom = $n; Email = $e }
        }
    )

    $racine = $script:CtxRoot

    $git = $null
    try { $git = & $LecteurGit } catch { $git = $null }

    $contextes = @()
    try { $contextes = @(Get-CtxManifests) } catch { $contextes = @() }

    $statutGh = ''
    try { $statutGh = & $LecteurGh } catch { $statutGh = '' }

    $coffre = $false
    try { $coffre = [bool](Get-Module -ListAvailable Microsoft.PowerShell.SecretManagement -ErrorAction SilentlyContinue) }
    catch { $coffre = $false }

    [pscustomobject]@{
        Coffre        = $coffre
        ShimsDansPath = [bool](Test-CtxShimsDansPath)
        Racine        = $racine
        RacineExiste  = [bool]($racine -and (Test-Path -LiteralPath $racine))
        NbContextes   = $contextes.Count
        GitNom        = Get-CtxProp $git 'Nom'
        GitEmail      = Get-CtxProp $git 'Email'
        ComptesGh     = @(Get-CtxGhComptesDepuisStatut -Texte $statutGh)
    }
}

function Test-CtxShimsDansPath {
    <#
      Les shims sont-ils joignables par le PATH de CE processus ?

      On interroge la COMMANDE, jamais le fichier. Un shim present sur disque et
      jamais atteint est le defaut exact du 16 aout 2026 : le garde-fou etait
      pose, annonce actif, et un binaire du PATH systeme passait devant.
      Verifier l'existence du fichier aurait repondu "oui" tout du long.
    #>

    [CmdletBinding()]
    param([string[]]$Dossiers = (Get-CtxShimDirs))

    $trouves = Get-Command 'supabase' -CommandType Application, ExternalScript -All -ErrorAction SilentlyContinue
    foreach ($t in @($trouves)) {
        if (Test-CtxDossierEstShimDevContext -Dossier (Split-Path $t.Source -Parent) -Dossiers $Dossiers) { return $true }
    }
    $false
}

function Resolve-CtxInitEtapes {
    <#
      PURE. Que reste-t-il a faire, et dans quel ordre ?

      Chaque etape porte SA commande. C'est ce qui permet au mode non interactif
      d'etre un citoyen a part entiere plutot qu'un mode degrade : il affiche
      exactement ce que le mode interactif aurait execute.

      L'ordre n'est pas cosmetique. Le coffre d'abord, parce que creer un
      contexte sans lui echoue a mi-chemin en laissant un dossier derriere.
      Les shims ensuite, parce qu'ils ne dependent d'aucun contexte. Le contexte
      en dernier, parce qu'il est le seul a demander des informations.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Faits)

    $etapes = @()

    # 1. Le coffre. NON automatisable : installer un module est une dependance
    # nouvelle, et cela ne se fait pas au nom de quelqu'un sans son accord.
    $etapes += [pscustomobject]@{
        Cle       = 'coffre'
        Fait      = [bool](Get-CtxProp $Faits 'Coffre')
        Auto      = $false
        Commande  = 'Install-Module Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser'
    }

    # 2. Les shims. Automatisable : l'installateur est idempotent et reversible.
    $etapes += [pscustomobject]@{
        Cle       = 'shims'
        Fait      = [bool](Get-CtxProp $Faits 'ShimsDansPath')
        Auto      = $true
        Commande  = 'pwsh -File installer-shims.ps1'
    }

    # 3. Un premier contexte. Automatisable, mais il faut des informations --
    # c'est la seule etape qui exige vraiment de pouvoir poser des questions.
    $nb = [int](Get-CtxProp $Faits 'NbContextes' 0)

    # [0] sur un tableau VIDE leve "Index was outside the bounds of the array" --
    # et une machine vierge, qui est precisement la cible de cette commande, n'a
    # aucun compte gh. Get-CtxProp rend bien le tableau vide plutot que $null,
    # donc c'est l'INDEXATION qu'il faut garder, pas la lecture.
    $comptes = @(Get-CtxProp $Faits 'ComptesGh')
    $login = if ($comptes.Count -gt 0) { $comptes[0] } else { '' }

    $nom = Resolve-CtxNomPropose -Login $login -Email (Get-CtxProp $Faits 'GitEmail')
    $email = Get-CtxProp $Faits 'GitEmail'
    $exemple = if ($email) { "ctx-new -Name $nom -Email $email -Root <dossier>" }
    else { 'ctx-new -Name perso -Email vous@exemple.com -Root <dossier>' }

    $etapes += [pscustomobject]@{
        Cle       = 'contexte'
        Fait      = ($nb -gt 0)
        Auto      = $true
        Commande  = $exemple
        Propose   = [pscustomobject]@{ Nom = $nom; Email = $email }
    }

    @($etapes)
}

function Test-CtxPeutDemander {
    <#
      Peut-on poser une question et en entendre la reponse ?

      Sur une entree redirigee, une invite ne met pas en pause : elle lit une fin
      de fichier, et selon le cmdlet elle boucle ou prend un defaut que personne
      n'a choisi. Un agent, une tache planifiee et un `|` sont tous dans ce cas.

      Injectable pour que les deux chemins soient testables sans avoir a rediriger
      l'entree du processus de test.
    #>

    [CmdletBinding()]
    param([scriptblock]$Sonde = { -not [Console]::IsInputRedirected })
    try { [bool](& $Sonde) } catch { $false }
}