[ADD] scripts: Windows install and run through PowerShell 5.1

install.cmd, install_dev.cmd and run.cmd hand over to PowerShell
scripts that place Node of the .node-version major under %LOCALAPPDATA%,
checked against the SHA-256 that nodejs.org publishes, then run npm ci:
no administrator right is needed. The scripts stay within Windows
PowerShell 5.1, shipped with Windows, and carry a UTF-8 byte order mark,
without which 5.1 reads them in the system code page and mangles
accents. The licence guard now reads .ps1, .cmd, .nix and Makefile.

Checked: parsed and run under pwsh in a container, Node for win-x64
downloaded, verified and extracted; not yet run on Windows itself.

--- FR ---

[ADD] scripts : installer et lancer sous Windows, par PowerShell 5.1

install.cmd, install_dev.cmd et run.cmd passent la main à des scripts
PowerShell qui posent Node de la majeure de .node-version sous
%LOCALAPPDATA%, vérifié contre la somme SHA-256 que publie nodejs.org,
puis lancent npm ci : aucun droit d'administrateur n'est requis. Les
scripts s'en tiennent à Windows PowerShell 5.1, livré avec Windows, et
portent une marque d'ordre d'octets UTF-8, sans laquelle 5.1 les lit
dans la page de code du système et défigure les accents. La garde de
licence lit désormais les .ps1, .cmd, .nix et le Makefile.

Vérifié : analysés et exécutés sous pwsh en conteneur, Node win-x64
téléchargé, vérifié, extrait ; pas encore exécutés sous Windows même.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
Mathieu Benoit 2026-10-06 05:51:31 -04:00
parent de46c582f4
commit 67368b719f
9 changed files with 325 additions and 16 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.cmd text eol=crlf

6
install.cmd Normal file
View file

@ -0,0 +1,6 @@
@REM © 2026 TechnoLibre (http://www.technolibre.ca)
@REM License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
@echo off
rem Installe de quoi lancer l'application depuis les sources : scripts\windows\installer.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\installer.ps1" %*
exit /b %ERRORLEVEL%

6
install_dev.cmd Normal file
View file

@ -0,0 +1,6 @@
@REM © 2026 TechnoLibre (http://www.technolibre.ca)
@REM License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
@echo off
rem Installe l'environnement de developpement : scripts\windows\installer.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\installer.ps1" -Dev %*
exit /b %ERRORLEVEL%

6
run.cmd Normal file
View file

@ -0,0 +1,6 @@
@REM © 2026 TechnoLibre (http://www.technolibre.ca)
@REM License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
@echo off
rem Lance l'application depuis les sources : scripts\windows\lancer.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows\lancer.ps1" %*
exit /b %ERRORLEVEL%

View file

@ -0,0 +1,33 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Exécute une commande à la racine du projet, le Node du projet en tête du
# PATH, une fois l'installation vérifiée : c'est par lui que le Makefile
# lance npm, npx et node sous Windows.
#
# powershell -File scripts\windows\avec_node.ps1 npm test
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Commande)
. (Join-Path $PSScriptRoot 'commun.ps1')
$elements = @($Commande | Where-Object { $_ })
if ($elements.Count -eq 0) {
Write-Gtt 'usage : scripts\windows\avec_node.ps1 <commande> [argument…]'
exit 2
}
Initialize-Gtt
Push-Location $script:GttRacine
try {
$programme = $elements[0]
# npm et npx se lancent par leur .cmd : leur variante .ps1 dépend de la
# stratégie d'exécution de la machine.
if (@('npm', 'npx') -contains $programme) { $programme = "$programme.cmd" }
if ($elements.Count -gt 1) {
$reste = $elements[1..($elements.Count - 1)]
& $programme @reste
} else {
& $programme
}
exit $LASTEXITCODE
} finally {
Pop-Location
}

171
scripts/windows/commun.ps1 Normal file
View file

@ -0,0 +1,171 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Fonctions communes des scripts Windows : le Node du projet, posé hors du
# dépôt sous %LOCALAPPDATA%, et la vérification de l'installation. Ce
# fichier se charge par « . ». Il s'en tient à Windows PowerShell 5.1, livré
# avec Windows : ni ??, ni ?., ni opérateur ternaire. Il est enregistré en
# UTF-8 avec marque d'ordre d'octets, sans laquelle Windows PowerShell 5.1 le
# lit dans la page de code du système et défigure les accents.
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 3.0
# Sans ces réglages, Windows PowerShell 5.1 écrit les accents dans la page de
# code de la console, peut refuser TLS 1.2 que nodejs.org exige, et ralentit
# chaque téléchargement par sa barre de progression.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$ProgressPreference = 'SilentlyContinue'
$script:GttRacine = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Write-Gtt([string]$Message) {
[Console]::Error.WriteLine($Message)
}
function Stop-Gtt([string]$Message) {
Write-Gtt "Refusé : $Message"
exit 1
}
# Majeure de Node que vise le projet ; .node-version en est la seule source.
function Get-GttMajeure {
$majeure = (Get-Content -Raw (Join-Path $script:GttRacine '.node-version')).Trim()
if ($majeure -notmatch '^[0-9]+$') {
Stop-Gtt ".node-version doit porter une majeure entière : « $majeure »."
}
return $majeure
}
# Architecture au sens de nodejs.org. PROCESSOR_ARCHITEW6432 nomme celle du
# système quand PowerShell lui-même tourne en 32 bits.
function Get-GttArchNode {
$arch = $env:PROCESSOR_ARCHITEW6432
if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE }
switch ($arch) {
'AMD64' { return 'x64' }
'ARM64' { return 'arm64' }
default { Stop-Gtt "architecture $arch : nodejs.org n'y publie pas Node pour Windows." }
}
}
function Get-GttDossierDonnees {
return (Join-Path $env:LOCALAPPDATA 'gestion_table_tournante_libre')
}
function Get-GttDossierNode {
return (Join-Path (Get-GttDossierDonnees) ('node-{0}-win-{1}' -f (Get-GttMajeure), (Get-GttArchNode)))
}
# Somme et nom de l'archive .zip d'une architecture dans les lignes d'un
# SHASUMS256.txt de nodejs.org ; $null quand elle n'y figure pas.
function Find-GttArchiveNode([string[]]$Lignes, [string]$Arch) {
$suffixe = "-win-$Arch.zip"
foreach ($ligne in $Lignes) {
$champs = @($ligne.Trim() -split '\s+')
if ($champs.Count -ge 2 -and $champs[1] -match '^node-v[0-9]+\.[0-9]+\.[0-9]+-' -and
$champs[1].EndsWith($suffixe, [StringComparison]::Ordinal)) {
return @($champs[0], $champs[1])
}
}
return $null
}
# Pose la dernière version publiée de la majeure voulue, une fois. L'archive
# est vérifiée contre la somme que publie nodejs.org, puis extraite à côté de
# la cible et renommée : une extraction interrompue ne laisse jamais une
# cible à moitié écrite.
function Install-GttNode {
$majeure = Get-GttMajeure
$arch = Get-GttArchNode
$cible = Get-GttDossierNode
$node = Join-Path $cible 'node.exe'
if (Test-Path $node) {
Write-Gtt ('Node {0} : {1}' -f (& $node --version), $cible)
return
}
$cache = Join-Path (Get-GttDossierDonnees) 'cache'
New-Item -ItemType Directory -Force -Path $cache | Out-Null
$base = "https://nodejs.org/dist/latest-v$majeure.x"
$sommes = Join-Path $cache "SHASUMS256-v$majeure.txt"
Invoke-WebRequest -UseBasicParsing -Uri "$base/SHASUMS256.txt" -OutFile $sommes
$trouve = Find-GttArchiveNode (Get-Content $sommes) $arch
if (-not $trouve) { Stop-Gtt "nodejs.org ne publie pas Node $majeure pour win-$arch." }
$somme = $trouve[0]
$nom = $trouve[1]
$archive = Join-Path $cache $nom
if (-not (Test-Path $archive) -or (Get-FileHash -Algorithm SHA256 $archive).Hash -ne $somme) {
Write-Gtt "Téléchargement de $nom"
$partiel = "$archive.$PID.partiel"
Invoke-WebRequest -UseBasicParsing -Uri "$base/$nom" -OutFile $partiel
if ((Get-FileHash -Algorithm SHA256 $partiel).Hash -ne $somme) {
Remove-Item -Force $partiel
Stop-Gtt "$nom : la somme SHA-256 ne correspond pas à celle que publie nodejs.org."
}
Move-Item -Force $partiel $archive
}
$tmp = Join-Path (Get-GttDossierDonnees) ('extraction.' + [guid]::NewGuid().ToString('N'))
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $tmp)
if (Test-Path $cible) { Remove-Item -Recurse -Force $cible }
Move-Item (Join-Path $tmp ([IO.Path]::GetFileNameWithoutExtension($nom))) $cible
Remove-Item -Force $tmp
Write-Gtt ('Node {0} posé : {1}' -f (& $node --version), $cible)
}
# Met le Node du projet en tête du PATH de la séance ; faux quand il n'est
# pas posé.
function Enable-GttNode {
$cible = Get-GttDossierNode
if (-not (Test-Path (Join-Path $cible 'node.exe'))) { return $false }
$env:Path = "$cible;$env:Path"
return $true
}
# Vrai quand les dépendances npm suivent package-lock.json : npm écrit
# node_modules\.package-lock.json à chaque installation.
function Test-GttDependances {
$recu = Join-Path $script:GttRacine 'node_modules\.package-lock.json'
if (-not (Test-Path $recu)) { return $false }
$verrou = Get-Item (Join-Path $script:GttRacine 'package-lock.json')
return ($verrou.LastWriteTimeUtc -le (Get-Item $recu).LastWriteTimeUtc)
}
# Vrai sur une réponse affirmative — o, oui, y, yes. Le défaut est non, et
# une entrée redirigée ne consent à rien. GTT_OUI=1 répond oui d'avance.
function Confirm-Gtt([string]$Question) {
if ($env:GTT_OUI -eq '1') { return $true }
if ([Console]::IsInputRedirected) { return $false }
$reponse = Read-Host "$Question [o/N]"
return (@('o', 'oui', 'y', 'yes') -contains $reponse.Trim().ToLowerInvariant())
}
# Exécute un programme et refuse quand il échoue, en le nommant.
function Invoke-Gtt([string]$Programme, [string[]]$Arguments) {
& $Programme @Arguments
if ($LASTEXITCODE -ne 0) { Stop-Gtt "$Programme $($Arguments -join ' ') échoue (code $LASTEXITCODE)." }
}
function Invoke-GttNpmCi {
Invoke-Gtt 'npm.cmd' @('ci', '--no-audit', '--no-fund')
}
# Rend prêt à exécuter : le Node du projet en tête du PATH, et des
# dépendances npm qui suivent package-lock.json. Node absent, il propose
# install.cmd ; des dépendances en retard se réinstallent sans question.
function Initialize-Gtt {
if (-not (Enable-GttNode)) {
Write-Gtt "Node $(Get-GttMajeure) n'est pas posé."
if (-not (Confirm-Gtt 'Lancer install.cmd (Node, dépendances npm) ?')) {
Stop-Gtt 'installation non lancée. Lancer install.cmd, puis recommencer.'
}
& (Join-Path $PSScriptRoot 'installer.ps1')
if (-not (Enable-GttNode)) { Stop-Gtt "Node $(Get-GttMajeure) reste introuvable après l'installation." }
}
if (-not (Test-GttDependances)) {
Write-Gtt 'Les dépendances npm ne suivent pas package-lock.json : npm ci.'
Push-Location $script:GttRacine
try { Invoke-GttNpmCi } finally { Pop-Location }
}
}

View file

@ -0,0 +1,23 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Installe sous Windows de quoi lancer l'application depuis les sources et
# jouer les épreuves : Node de la majeure de .node-version, posé sous
# %LOCALAPPDATA% sans droits d'administrateur, et les dépendances de
# package-lock.json. -Dev ajoute le Chromium de Playwright des épreuves du
# navigateur. install.cmd et install_dev.cmd l'appellent.
param([switch]$Dev)
. (Join-Path $PSScriptRoot 'commun.ps1')
Install-GttNode
[void](Enable-GttNode)
Push-Location $script:GttRacine
try {
Invoke-GttNpmCi
if ($Dev) {
Invoke-Gtt 'npx.cmd' @('--no-install', 'playwright', 'install', '--only-shell', 'chromium')
}
} finally {
Pop-Location
}
Write-Gtt 'Installation terminée. Lancer : run.cmd'

View file

@ -0,0 +1,38 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Lance l'application depuis les sources, sous Windows : la coquille
# Electron charge la page que Vite construit dans www\ (§ 13.1). run.cmd
# l'appelle, ses options sont celles de run.sh.
#
# run.cmd Electron
# run.cmd --web le serveur de Vite, http://localhost:5173
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Options)
. (Join-Path $PSScriptRoot 'commun.ps1')
$mode = 'electron'
foreach ($option in @($Options | Where-Object { $_ })) {
switch ($option) {
'--web' { $mode = 'web' }
'--electron' { $mode = 'electron' }
default {
Write-Gtt 'usage : run.cmd [--web | --electron]'
exit 2
}
}
}
Initialize-Gtt
Push-Location $script:GttRacine
try {
if ($mode -eq 'web') {
Write-Gtt "Serveur de développement de Vite : http://localhost:5173 (Ctrl+C pour l'arrêter)."
& npm.cmd run --silent dev
exit $LASTEXITCODE
}
Invoke-Gtt 'npm.cmd' @('run', '--silent', 'build')
& npx.cmd --no-install electron .
exit $LASTEXITCODE
} finally {
Pop-Location
}

View file

@ -3,12 +3,13 @@
// Chaque fichier source du projet s'ouvre sur l'en-tête de licence : la ligne
// du titulaire, puis celle de la licence, après un éventuel shebang ou la
// déclaration <!doctype html>. Les deux lignes sont des commentaires du
// déclaration <!doctype html>, et après la marque d'ordre d'octets que
// portent les scripts PowerShell. Les deux lignes sont des commentaires du
// langage du fichier ; le texte ne varie pas. package.json déclare la même
// licence et le même titulaire, que reprennent les outils d'emballage.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { extname, join, relative, sep } from 'node:path';
import { basename, extname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from './lanceur.js';
@ -17,8 +18,11 @@ const RACINE = fileURLToPath(new URL('..', import.meta.url));
const TITULAIRE = /^© \d{4} TechnoLibre \(http:\/\/www\.technolibre\.ca\)$/;
const LICENCE = 'License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)';
// Marqueurs de commentaire, par extension de fichier source. Un commentaire
// de ligne n'a pas de fermant.
// Marqueurs de commentaire, par extension de fichier source, ou par nom pour
// un fichier sans extension. Un commentaire de ligne n'a pas de fermant. Un
// ouvrant est une chaîne qui ouvre la ligne, ou un motif : REM ouvre une
// ligne de commandes Windows, précédé ou non de @, quelle que soit sa casse,
// et suivi d'un blanc — @REMARQUE est une commande.
const COMMENTAIRE = new Map([
['.js', { ouvrant: '//', fermant: null }],
['.cjs', { ouvrant: '//', fermant: null }],
@ -27,8 +31,16 @@ const COMMENTAIRE = new Map([
['.svelte', { ouvrant: '<!--', fermant: '-->' }],
['.html', { ouvrant: '<!--', fermant: '-->' }],
['.css', { ouvrant: '/*', fermant: '*/' }],
['.ps1', { ouvrant: '#', fermant: null }],
['.nix', { ouvrant: '#', fermant: null }],
['.cmd', { ouvrant: /^@?rem(?=\s)/i, fermant: null }],
['Makefile', { ouvrant: '#', fermant: null }],
]);
// Clé d'un fichier dans COMMENTAIRE : son nom quand il y figure, son
// extension sinon.
const cle = (chemin) => (COMMENTAIRE.has(basename(chemin)) ? basename(chemin) : extname(chemin));
// Périmètre : les fichiers de la racine et les arbres des sources, des
// épreuves, des scripts et de la coquille. Les dépendances et les sorties de
// construction n'y entrent pas, ni les données d'épreuve, qui sont des
@ -55,7 +67,7 @@ function fichiersSources() {
}
}
return chemins
.filter((chemin) => COMMENTAIRE.has(extname(chemin)))
.filter((chemin) => COMMENTAIRE.has(cle(chemin)))
.filter((chemin) => !chemin.startsWith(DONNEES_EPREUVE))
.sort();
}
@ -69,8 +81,9 @@ function fichiersSources() {
// invalidée dans une feuille, une commande dans un script.
function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
const suite = seconde.trim();
if (!premiere.startsWith(ouvrant)) return false;
if (fermant === null) return suite.startsWith(ouvrant);
const ouvre = (ligne) => (typeof ouvrant === 'string' ? ligne.startsWith(ouvrant) : ouvrant.test(ligne));
if (!ouvre(premiere)) return false;
if (fermant === null) return ouvre(suite);
const courant = !premiere.includes(fermant) && suite.endsWith(fermant);
const repete =
premiere.trimEnd().endsWith(fermant) &&
@ -79,21 +92,22 @@ function enCommentaire(premiere, seconde, { ouvrant, fermant }) {
return courant || repete;
}
// Vrai quand le contenu s'ouvre sur l'en-tête, après un éventuel shebang ou
// la déclaration <!doctype html> : deux lignes de commentaire du langage de
// l'extension, dont le texte, débarrassé des marqueurs, est celui de
// l'en-tête.
function porteLEnTete(contenu, extension) {
let lignes = contenu.split('\n');
// Vrai quand le contenu s'ouvre sur l'en-tête, après une éventuelle marque
// d'ordre d'octets, un shebang ou la déclaration <!doctype html> : deux
// lignes de commentaire du langage de la clé, extension ou nom, dont le
// texte, débarrassé des marqueurs, est celui de l'en-tête. Les fins de ligne
// sont celles d'Unix ou de Windows.
function porteLEnTete(contenu, cleFichier) {
let lignes = contenu.replace(/^\uFEFF/, '').split(/\r?\n/);
if (lignes[0].startsWith('#!')) lignes = lignes.slice(1);
if (/^<!doctype html>$/i.test(lignes[0])) lignes = lignes.slice(1);
const [premiere = '', seconde = ''] = lignes;
const texte = (ligne) =>
ligne
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*)?\s*/, '')
.replace(/^\s*(?:\/\/|#|<!--|\/\*|\*|@?rem(?=\s))?\s*/i, '')
.replace(/\s*(?:-->|\*\/)?\s*$/, '');
return (
enCommentaire(premiere, seconde, COMMENTAIRE.get(extension)) &&
enCommentaire(premiere, seconde, COMMENTAIRE.get(cleFichier)) &&
TITULAIRE.test(texte(premiere)) &&
texte(seconde) === LICENCE
);
@ -104,7 +118,7 @@ describe('licence', () => {
const fichiers = fichiersSources();
assert.ok(fichiers.length > 0, 'aucun fichier source examiné');
const sansEnTete = fichiers
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), extname(chemin)))
.filter((chemin) => !porteLEnTete(readFileSync(chemin, 'utf8'), cle(chemin)))
.map((chemin) => relative(RACINE, chemin));
assert.deepEqual(sansEnTete, []);
});
@ -129,6 +143,16 @@ describe('licence', () => {
['.html', `<!-- ${titulaire}\n ${LICENCE}\n-->\n`, false],
['.css', `/* ${titulaire} */\n${LICENCE}\n`, false],
['.css', `/* ${titulaire} */\n * ${LICENCE} */\n`, false],
['.ps1', `\uFEFF# ${titulaire}\r\n# ${LICENCE}\r\n`, true],
['.ps1', `# ${titulaire}\n# ${LICENCE}\n`, true],
['.nix', `# ${titulaire}\n# ${LICENCE}\n`, true],
['.cmd', `@REM ${titulaire}\r\n@REM ${LICENCE}\r\n@echo off\r\n`, true],
['.cmd', `REM ${titulaire}\r\nrem ${LICENCE}\r\n`, true],
['Makefile', `# ${titulaire}\n# ${LICENCE}\n`, true],
['.ps1', `\uFEFF# ${titulaire}\r\nWrite-Host '${LICENCE}'\r\n`, false],
['.cmd', `@REM ${titulaire}\r\n@echo ${LICENCE}\r\n`, false],
['.cmd', `@REMARQUE ${titulaire}\r\n@REM ${LICENCE}\r\n`, false],
['Makefile', `# ${titulaire}\n\t${LICENCE}\n`, false],
];
const ecarts = cas
.filter(([extension, contenu, admis]) => porteLEnTete(contenu, extension) !== admis)