<# .SYNOPSIS Met un poste Data Monkeys dans l'etat voulu : logiciels, depots, plugins, tache, identite. .DESCRIPTION Un seul script pour l'installation d'un poste et pour sa mise a jour. Il ne "fait pas l'installation" : il compare l'etat du poste a l'etat voulu et ne corrige que l'ecart. Donc il se relance sans risque, aussi souvent qu'on veut, et c'est ainsi qu'une evolution de la methode (nouveau plugin, comportement des notifications qui change, nouvelle tache) se propage : chacun le relance, son poste reconverge. Deroule : il verifie tout, affiche le plan (ce qui est deja fait, ce qu'il va poser, ce qui reste a faire a la main), demande une seule confirmation, applique, puis reverifie. Ce qu'il ne fait jamais : demander un mot de passe ou un jeton. L'authentification GitHub passe par `gh auth login` (navigateur, jeton range dans le gestionnaire d'identifiants Windows), celle de Claude Code par son propre ecran de connexion. Un script qui collecte un secret le laisse fuir dans l'historique du terminal, les journaux ou un fichier de configuration. .PARAMETER Root Dossier des depots systeme. Convention d'equipe : C:\Dev\_datamonkeys .PARAMETER Verifier N'ecrit rien : affiche l'etat du poste et sort. Code de sortie 1 si quelque chose manque. C'est le mode du parrain qui controle un poste avant d'ouvrir les acces. .PARAMETER Oui Ne pose pas la question de confirmation (usage automatise). A eviter sur un poste neuf. .PARAMETER Seulement Ne traite que les etapes dont le nom contient un de ces motifs. Utile pour reconverger un point precis : -Seulement 'Plugins', 'Tache'. .EXAMPLE .\scripts\installer-poste.ps1 .EXAMPLE .\scripts\installer-poste.ps1 -Verifier .NOTES Sur un poste totalement neuf, ce script est inaccessible (depot prive) : c'est scripts/amorce.ps1, servi a https://get.datamonkeys.be, qui installe Git et GitHub CLI, connecte GitHub, clone dm-platform et lance ce script. Voir scripts/README.md. #> [CmdletBinding()] param( [string]$Root = 'C:\Dev\_datamonkeys', [switch]$Verifier, [switch]$Oui, [string[]]$Seulement ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' $Organisation = 'Data-Monkeys' $Marketplace = 'data-monkeys' $Plugins = @('nouveau-projet', 'nouveau-client', 'retex', 'brain-hooks') # Plugins reserves a ceux qui accueillent les nouveaux membres, par compte GitHub. Une liste courte et # relue par PR plutot qu'une regle : le role Owner de l'organisation ne couvre pas tous les # conducteurs d'onboarding (Patrick est membre, pas Owner). $PluginsConducteurs = @{ 'onboarding' = @('KingKong4537', 'Patrick-DataMonkeys') } $DepotsObligatoires = @('dm-brain', 'dm-platform') $DepotOptionnel = 'dm-clients' # acces restreint : son absence n'est pas une anomalie $TacheNom = 'DataMonkeys\brain-sync' # --------------------------------------------------------------------------------------------- # Outils # --------------------------------------------------------------------------------------------- function Trouver-Commande { param([Parameter(Mandatory)][string]$Nom) Get-Command $Nom -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 } function Invoke-Externe { <# Lance un executable et renvoie son code de sortie et sa sortie, sans faire echouer le script. #> param( [Parameter(Mandatory)][string]$Fichier, [string[]]$Arguments = @(), [switch]$Interactif ) if ($Interactif) { # L'utilisateur doit voir et repondre : on laisse la console au processus. & $Fichier @Arguments return [pscustomobject]@{ Ok = ($LASTEXITCODE -eq 0); Sortie = '' } } $sortie = & $Fichier @Arguments 2>&1 | Out-String [pscustomobject]@{ Ok = ($LASTEXITCODE -eq 0); Sortie = $sortie.Trim() } } function Etat { param([Parameter(Mandatory)][bool]$Ok, [Parameter(Mandatory)][string]$Texte) [pscustomobject]@{ Ok = $Ok; Texte = $Texte } } function Etape { param( [Parameter(Mandatory)][string]$Nom, [Parameter(Mandatory)][scriptblock]$Controle, [scriptblock]$Action, [string]$Manuel ) [pscustomobject]@{ Nom = $Nom; Controle = $Controle; Action = $Action; Manuel = $Manuel } } function Installer-Paquet { param([Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][string]$Nom) $winget = Trouver-Commande 'winget' if (-not $winget) { throw "winget est absent : installer $Nom a la main, puis relancer" } Write-Host " installation de $Nom via winget (une fenetre d'elevation peut s'ouvrir)..." $r = Invoke-Externe $winget.Source @('install', '--id', $Id, '--exact', '--source', 'winget', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') if (-not $r.Ok) { throw "winget n'a pas installe $Nom : $($r.Sortie)" } # Le PATH de la session courante ne contient pas encore le nouveau programme. $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') } function Plugins-Attendus { <# Les plugins de tout le monde, plus ceux reserves au compte GitHub connecte s'il est conducteur. #> $attendus = @($Plugins) $gh = Trouver-Commande 'gh' if ($gh) { $login = Invoke-Externe $gh.Source @('api', 'user', '--jq', '.login') if ($login.Ok) { foreach ($plugin in $PluginsConducteurs.Keys) { if ($PluginsConducteurs[$plugin] -contains $login.Sortie) { $attendus += $plugin } } } } $attendus } function Depot-Distant { param([Parameter(Mandatory)][string]$Nom) 'https://github.com/{0}/{1}.git' -f $Organisation, $Nom } # --------------------------------------------------------------------------------------------- # Les etapes, dans l'ordre ou elles dependent les unes des autres # --------------------------------------------------------------------------------------------- $etapes = @( Etape 'Windows PowerShell 5.1' { $ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' if (Test-Path -LiteralPath $ps) { Etat $true 'present (utilise par la tache planifiee)' } else { Etat $false 'introuvable : poste inhabituel, demander de l aide' } } Etape 'winget' { if (Trouver-Commande 'winget') { Etat $true 'present' } else { Etat $false 'absent : installer "Programme d installation d applications" depuis le Microsoft Store' } } -Manuel 'Installer "Programme d installation d applications" depuis le Microsoft Store, puis relancer.' Etape 'Git' { $git = Trouver-Commande 'git' if ($git) { Etat $true ((Invoke-Externe $git.Source @('--version')).Sortie) } else { Etat $false 'absent' } } -Action { Installer-Paquet -Id 'Git.Git' -Nom 'Git' } Etape 'GitHub CLI' { $gh = Trouver-Commande 'gh' if ($gh) { Etat $true (((Invoke-Externe $gh.Source @('--version')).Sortie -split "`r?`n")[0]) } else { Etat $false 'absent' } } -Action { Installer-Paquet -Id 'GitHub.cli' -Nom 'GitHub CLI' } Etape 'Claude Code' { $claude = Trouver-Commande 'claude' if ($claude) { Etat $true ((Invoke-Externe $claude.Source @('--version')).Sortie) } else { Etat $false 'absent' } } -Action { # Installateur officiel Anthropic, par utilisateur, sans droits administrateur. Write-Host ' installation de Claude Code (script officiel claude.ai/install.ps1)...' Invoke-RestMethod -Uri 'https://claude.ai/install.ps1' | Invoke-Expression $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') } Etape 'Authentification GitHub' { $gh = Trouver-Commande 'gh' if (-not $gh) { return Etat $false 'GitHub CLI absent' } if ((Invoke-Externe $gh.Source @('auth', 'status')).Ok) { Etat $true 'connecte' } else { Etat $false 'non connecte' } } -Action { $gh = Trouver-Commande 'gh' Write-Host ' connexion GitHub : ton navigateur va s ouvrir, aucun mot de passe ne transite par ce script' $null = Invoke-Externe $gh.Source @('auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web') -Interactif # Git doit pouvoir se connecter aux depots prives avec ce meme compte. $null = Invoke-Externe $gh.Source @('auth', 'setup-git') } Etape 'Identite Git (nom et email)' { $git = Trouver-Commande 'git' if (-not $git) { return Etat $false 'Git absent' } $email = (Invoke-Externe $git.Source @('config', '--global', 'user.email')).Sortie $nom = (Invoke-Externe $git.Source @('config', '--global', 'user.name')).Sortie if (-not $email -or -not $nom) { return Etat $false 'nom ou email absent de la configuration Git' } $note = '' $gh = Trouver-Commande 'gh' if ($gh) { $connus = Invoke-Externe $gh.Source @('api', 'user/emails', '--jq', '.[].email') if ($connus.Ok) { # Un commit signe d'une adresse inconnue de GitHub n'est attribue a personne. if (($connus.Sortie -split "`r?`n") -notcontains $email) { return Etat $false ("$email n est pas declaree sur le compte GitHub") } } else { # Jeton sans le scope "user" : on ne sait pas, et on ne fait pas dire a une commande # en echec ce qu elle n a pas dit. `gh auth refresh -h github.com -s user` le donne. $note = ' (declaration GitHub non verifiee : jeton sans le scope "user")' } } Etat $true "$nom <$email>$note" } -Action { $git = Trouver-Commande 'git' $gh = Trouver-Commande 'gh' if (-not $gh) { throw 'GitHub CLI necessaire pour retrouver le nom et l adresse du compte' } $reponse = Invoke-Externe $gh.Source @('api', 'user/emails', '--jq', '[.[] | select(.primary)] | .[0].email') if (-not $reponse.Ok) { throw ('adresses du compte illisibles ({0}). Donner le scope au jeton : ' -f ($reponse.Sortie -split "`r?`n")[0] + 'gh auth refresh -h github.com -s user, puis relancer') } $email = $reponse.Sortie $nom = (Invoke-Externe $gh.Source @('api', 'user', '--jq', '.name // .login')).Sortie if (-not $email) { throw 'adresse principale introuvable sur le compte GitHub' } $null = Invoke-Externe $git.Source @('config', '--global', 'user.email', $email) $null = Invoke-Externe $git.Source @('config', '--global', 'user.name', $nom) Write-Host " identite Git reglee sur $nom <$email>" } Etape 'Depots systeme' { $manquants = @() foreach ($depot in $DepotsObligatoires) { if (-not (Test-Path -LiteralPath (Join-Path (Join-Path $Root $depot) '.git'))) { $manquants += $depot } } $optionnel = if (Test-Path -LiteralPath (Join-Path (Join-Path $Root $DepotOptionnel) '.git')) { "$DepotOptionnel present" } else { "$DepotOptionnel absent (acces restreint, normal sans projet client)" } if ($manquants) { Etat $false ('manquants sous {0} : {1}' -f $Root, ($manquants -join ', ')) } else { Etat $true ('{0} sous {1} ; {2}' -f ($DepotsObligatoires -join ', '), $Root, $optionnel) } } -Action { $git = Trouver-Commande 'git' if (-not (Test-Path -LiteralPath $Root)) { $null = New-Item -ItemType Directory -Path $Root -Force } foreach ($depot in ($DepotsObligatoires + $DepotOptionnel)) { $cible = Join-Path $Root $depot if (Test-Path -LiteralPath (Join-Path $cible '.git')) { continue } Write-Host " clonage de $depot..." $r = Invoke-Externe $git.Source @('clone', '--quiet', (Depot-Distant $depot), $cible) if (-not $r.Ok) { if ($depot -eq $DepotOptionnel) { Write-Host " $depot non clone : pas d acces, c est normal tant qu aucun projet client ne le demande" if (Test-Path -LiteralPath $cible) { Remove-Item -LiteralPath $cible -Recurse -Force -ErrorAction SilentlyContinue } } else { throw "clonage de $depot impossible : $($r.Sortie)" } } } } Etape 'Plugins Claude Code' { $claude = Trouver-Commande 'claude' if (-not $claude) { return Etat $false 'Claude Code absent' } $attendus = @(Plugins-Attendus) $liste = (Invoke-Externe $claude.Source @('plugin', 'list')).Sortie $manquants = @($attendus | Where-Object { $liste -notmatch [regex]::Escape("$_@$Marketplace") }) if ($manquants) { Etat $false ('a installer : {0}' -f ($manquants -join ', ')) } else { Etat $true ('les {0} plugins attendus sont installes' -f $attendus.Count) } } -Action { $claude = Trouver-Commande 'claude' $null = Invoke-Externe $claude.Source @('plugin', 'marketplace', 'add', "$Organisation/dm-platform") $null = Invoke-Externe $claude.Source @('plugin', 'marketplace', 'update', $Marketplace) $liste = (Invoke-Externe $claude.Source @('plugin', 'list')).Sortie foreach ($plugin in @(Plugins-Attendus)) { if ($liste -match [regex]::Escape("$plugin@$Marketplace")) { continue } Write-Host " installation du plugin $plugin..." $r = Invoke-Externe $claude.Source @('plugin', 'install', "$plugin@$Marketplace", '--scope', 'user', '--yes') if (-not $r.Ok) { throw "installation de $plugin impossible : $($r.Sortie)" } } } Etape 'Identite des notifications' { $cle = 'HKCU:\Software\Classes\AppUserModelId\Datamonkeys.brAIn' $raccourci = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\brAIn Data Monkeys.lnk' $manque = @() if (-not (Test-Path -LiteralPath $cle)) { $manque += 'cle' } if (-not (Test-Path -LiteralPath $raccourci)) { $manque += 'raccourci' } if ($manque) { Etat $false ('a poser : {0} (sinon les notifications arrivent sous "Windows PowerShell")' -f ($manque -join ' et ')) } else { Etat $true 'brAIn Data Monkeys' } } -Action { & (Join-Path $PSScriptRoot 'installer-identite-toast.ps1') if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { throw 'installer-identite-toast.ps1 a echoue' } } Etape 'Tache planifiee brain-sync' { $r = Invoke-Externe 'schtasks.exe' @('/Query', '/TN', $TacheNom) if ($r.Ok) { Etat $true 'installee (ouverture de session et toutes les heures)' } else { Etat $false 'absente' } } -Action { $modele = Join-Path $PSScriptRoot 'brain-sync-task.xml' if (-not (Test-Path -LiteralPath $modele)) { throw "modele de tache introuvable : $modele" } $temporaire = Join-Path $env:TEMP 'brain-sync-task.xml' (Get-Content -Raw -LiteralPath $modele).Replace('__UTILISATEUR__', "$env:USERDOMAIN\$env:USERNAME") | Set-Content -LiteralPath $temporaire -Encoding Unicode $r = Invoke-Externe 'schtasks.exe' @('/Create', '/TN', $TacheNom, '/XML', $temporaire, '/F') Remove-Item -LiteralPath $temporaire -ErrorAction SilentlyContinue if (-not $r.Ok) { throw "creation de la tache impossible : $($r.Sortie)" } } Etape 'Police Outfit' { $polices = @() foreach ($ruche in 'HKCU:', 'HKLM:') { $chemin = "$ruche\Software\Microsoft\Windows NT\CurrentVersion\Fonts" if (Test-Path -LiteralPath $chemin) { $polices += (Get-Item -LiteralPath $chemin).GetValueNames() } } if ($polices -match '^Outfit') { Etat $true 'installee' } else { Etat $false 'absente : les documents et slides de la maison s afficheront en Segoe UI' } } -Manuel 'Recuperer le brand pack (SharePoint > Marque), ouvrir le dossier des polices, selectionner les fichiers Outfit, clic droit > Installer.' ) # --------------------------------------------------------------------------------------------- # Deroule # --------------------------------------------------------------------------------------------- function Controler { param([Parameter(Mandatory)]$Etape) try { & $Etape.Controle } catch { Etat $false ("controle impossible : {0}" -f $_) } } Write-Host '' Write-Host "Poste Data Monkeys, etat courant (depots sous $Root)" Write-Host '' if ($Seulement) { $etapes = @($etapes | Where-Object { $nom = $_.Nom; @($Seulement | Where-Object { $nom -like "*$_*" }) }) if (-not $etapes) { Write-Host 'Aucune etape ne correspond a -Seulement.'; exit 1 } } $resultats = [ordered]@{} foreach ($etape in $etapes) { $etat = Controler $etape $resultats[$etape.Nom] = $etat $marque = if ($etat.Ok) { 'ok ' } elseif ($etape.Action) { 'a poser' } else { 'a faire' } Write-Host (' [{0}] {1,-28} {2}' -f $marque, $etape.Nom, $etat.Texte) } $aPoser = @($etapes | Where-Object { -not $resultats[$_.Nom].Ok -and $_.Action }) $aLaMain = @($etapes | Where-Object { -not $resultats[$_.Nom].Ok -and -not $_.Action }) Write-Host '' if ($aLaMain) { Write-Host 'A faire a la main (le script ne peut pas le faire a ta place) :' foreach ($etape in $aLaMain) { Write-Host (' - {0} : {1}' -f $etape.Nom, $etape.Manuel) } Write-Host '' } if ($Verifier) { if ($aPoser -or $aLaMain) { Write-Host 'Poste incomplet.'; exit 1 } Write-Host 'Poste complet.' exit 0 } if (-not $aPoser) { if (-not $aLaMain) { Write-Host 'Rien a faire : le poste est deja dans l etat voulu.' } exit 0 } Write-Host 'Ce script va :' foreach ($etape in $aPoser) { Write-Host (' - {0}' -f $etape.Nom) } Write-Host '' Write-Host 'Aucun mot de passe ne sera demande : la connexion GitHub passe par ton navigateur.' Write-Host '' if (-not $Oui) { $reponse = Read-Host 'Installer ? [O/n]' if ($reponse -and $reponse -notmatch '^(o|oui|y|yes)$') { Write-Host 'Abandon, rien n a ete modifie.'; exit 0 } } $echecs = @() foreach ($etape in $aPoser) { Write-Host '' Write-Host ('>> {0}' -f $etape.Nom) try { & $etape.Action $etat = Controler $etape if ($etat.Ok) { Write-Host (' fait : {0}' -f $etat.Texte) } else { Write-Host (' incomplet : {0}' -f $etat.Texte); $echecs += $etape.Nom } } catch { Write-Host (' echec : {0}' -f $_) $echecs += $etape.Nom } } Write-Host '' if ($echecs) { Write-Host ('Termine, avec {0} etape(s) en echec : {1}' -f $echecs.Count, ($echecs -join ', ')) Write-Host 'Relancer le script apres correction : il ne refera que ce qui manque.' exit 1 } Write-Host 'Poste a jour.' if ($aLaMain) { Write-Host 'Il reste les points "a faire a la main" listes plus haut.' } exit 0