Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ jobs:
CGO_ENABLED: "0"
run: >
go test ./deploy/ -count=1 -v
-run 'TestEveryPowerShellScript|TestTheBackupAndTheRestoreWorkOnAThrowawayDirectory'
-run 'TestEveryPowerShellScript|TestTheBackupAndTheRestoreWorkOnAThrowawayDirectory|TestTheFourthDoorCountsCodePointsLikeTheOtherThree|TestTheSecretPipeCarriesTheBytesTheBinaryWillRead|TestNobodyIsAskedWhenNobodyIsThere'

build:
name: Compilation croisée, zéro cgo
Expand Down
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ code-barres EAN-13 s'imprime, il la colle sur son sac, la caisse la scanne.

Réécriture complète d'une application Microsoft Access/VBA analysée en détail. La conception
est **terminée et validée** — `docs/02-architecture.md` en est le dossier — et
**l'implémentation est livrée** : binaire tagué, installé et éprouvé sur un poste réel
(balance GRAM sur port série, SATO WS408 en RAW, redémarrage de recette passé), dépôt public
sous AGPL-3.0.
**l'application est EN PRODUCTION** : recette passée en magasin et **deux semaines
d'exploitation réelle** tenues au 10/08/2026 (balance GRAM sur port série, SATO WS408 en
RAW, redémarrage de recette passé), dépôt public sous AGPL-3.0.

**Ce n'est donc pas un projet vierge : c'est du code livré qu'on modifie.** Trois
conséquences, et elles priment sur tout réflexe de démarrage :
Expand Down Expand Up @@ -93,6 +93,15 @@ triviale vers Windows, Linux et linux-arm64. SQLite via `modernc.org/sqlite`, ja

Ces points ont été tranchés après analyse ; les rouvrir demande une décision explicite.

- **Les deux installeurs marchent en couple. Une modification validée sur l'un se reporte
sur l'autre, dans les deux sens** (décision du propriétaire du produit, 10/08/2026).
`deploy/windows/install.ps1` et `deploy/linux/install.sh` doivent offrir les **mêmes
fonctionnalités d'installation** ; idem pour les deux bootstraps. Un écart est permis
quand la plateforme l'impose — le compte `openscale` n'a ni mot de passe ni shell, le
mode pilote n'existe que pour laisser Access relançable — mais **il se déclare avec sa
raison**, jamais en silence. La parité ne se surveille pas à l'œil : elle est tenue par
`deploy/parity_test.go`, dont la table de correspondance est **le seul endroit du dépôt
où la parité est déclarée**. Ajouter une option d'un côté sans l'autre doit rougir.
- **Ce qui est tenu sur l'étiquette est le contrat de caisse, pas le dessin** (ADR-051,
30/07/2026, remplace ADR-003). Un EAN-13 au plan d'ADR-028, zones de silence intactes,
HRI imprimée. La mise en page, la hauteur des barres, l'interligne et la bande HRI
Expand Down
257 changes: 185 additions & 72 deletions INSTALLATION.md

Large diffs are not rendered by default.

33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ OpenScale remplace une application Microsoft Access de 2015 encore en service, d
reprend les fonctionnalités et les contrats externes — le format du code-barres lu par
la caisse, la géométrie de l'étiquette — mais aucune ligne de code.

**Développement terminé, éprouvé sur banc réel — SATO WS408 et GRAM XFOC —, pas encore
en service.** Il reste la recette sur un poste pilote. Ce qui est prouvé, ce qui ne
l'est pas et ce qui reste ouvert : [SUIVI.md](SUIVI.md).
**En service en magasin.** La recette a été passée sur site, et le poste a tenu **deux
semaines d'exploitation réelle** — clients, bénévoles, étiquettes scannées en caisse —
avant que ce statut ne soit écrit ici. Ce n'est plus un banc : c'est un poste qui pèse.

Le matériel éprouvé est une **SATO WS408** en RAW et une **GRAM XFOC** sur port série.
Ce qui est prouvé, ce qui ne l'est pas et ce qui reste ouvert : [SUIVI.md](SUIVI.md) —
le fichier se tient à jour au fil de l'eau, et il nomme aussi ce qui a cassé.

## Installer un poste

Expand All @@ -33,10 +37,13 @@ curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/wi
</details>

La commande prend la dernière version publiée, **vérifie son empreinte avant de la
décompresser**, pose trois questions — mot de passe de la session du poste, production ou
pilote, ouverture de session automatique — puis installe tout : compte Windows dédié,
service, tâche du kiosque, réglages d'alimentation, fiche d'installation à ranger dans le
classeur du magasin. Comptez **15 minutes** jusqu'à la première étiquette, matériel branché.
décompresser**, puis pose **six questions** : trois sur la machine — mot de passe de la
session du poste, production ou pilote, ouverture de session automatique — et trois sur le
poste lui-même — **numéro, nom et mot de passe d'administration**. Elle installe ensuite
tout : compte Windows dédié, service, tâche du kiosque, réglages d'alimentation, fiche
d'installation à ranger dans le classeur du magasin. **Le poste sort utilisable** : il ne
reste qu'à lui désigner sa balance, son imprimante et son catalogue. Comptez
**15 minutes** jusqu'à la première étiquette, matériel branché.

Sur une **Debian 12 minimale**, une commande également — et **la même met à jour** un poste
déjà installé :
Expand All @@ -45,9 +52,15 @@ déjà installé :
curl -fsSL https://raw.githubusercontent.com/lostmind84/OpenScale/main/deploy/linux/bootstrap.sh | sudo sh
```

Elle choisit l'archive de la bonne architecture — `amd64` ou `arm64` —, vérifie son
empreinte avant de décompresser, et ne pose aucune question : sous Linux, il n'y en a
aucune à poser.
Elle choisit l'archive de la bonne architecture — `amd64` ou `arm64` — et vérifie son
empreinte avant de décompresser. **Les deux installeurs offrent les mêmes fonctionnalités**
et un test du dépôt refuse qu'ils divergent : `install.sh` demande lui aussi le numéro, le
nom et le mot de passe d'administration, et déclare la balance absente sur un poste neuf.
Trois questions de moins qu'à l'installation Windows, et c'est la plateforme qui le veut :
le compte `openscale` n'a ni mot de passe ni interpréteur, l'écran client démarre à
l'allumage sans session à ouvrir, et il n'y a pas d'application Access à laisser
relançable. Le one-liner ci-dessus, lui, **ne pose jamais de question** — sous
`curl … | sh`, l'entrée standard *est* le script — et reçoit les réponses en options.

Le parcours complet du bénévole — redémarrage de recette, balance, imprimante, catalogue,
et **l'installation par clé USB d'un poste sans Internet** — est dans
Expand Down
41 changes: 31 additions & 10 deletions SUIVI.md

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ recommencez.
> de passe avec `openscale config password` avant de relire.

**2. Redémarrer le poste.** L'application s'arrête et son service la relance. Comptez
quelques secondes, l'écran client revient tout seul. Le journal Windows enregistrera un
« arrêt inattendu » : **c'est normal**, c'est ainsi que le redémarrage est déclenché.
quelques secondes, l'écran client revient tout seul. Le bouton dit depuis combien de temps
il attend. Le journal Windows enregistrera un « arrêt inattendu » : **c'est normal**, c'est
ainsi que le redémarrage est déclenché.

- Si le bouton répond **« ce poste n'est pas lancé par un service »** (`ERR-SYS-10`),
personne ne le relancerait : installez-le en service avec
Expand All @@ -141,6 +142,34 @@ quelques secondes, l'écran client revient tout seul. Le journal Windows enregis
`& "C:\Program Files\OpenScale\openscale.exe" service restart`, ou
`sudo systemctl restart openscale` sur Linux.

### Le bouton reste sur « En cours… » et le poste ne revient pas

C'est le symptôme d'un poste **installé avant le 10/08/2026**, sur Windows. L'écran client
est noir pendant ce temps, et au bout de cinq minutes le bouton annonce que le poste n'a
pas répondu.

Ce qui se passe : le poste s'est bien arrêté, mais le gestionnaire de services Windows ne
l'a pas relancé. Ses actions de reprise étaient posées — vous pouvez le vérifier avec
`sc qfailure OpenScale` — mais elles ne s'appliquaient qu'à un **plantage**, jamais à un
arrêt propre comme celui-ci. Il y manquait un second réglage, que `sc qfailureflag
OpenScale` affiche à `FALSE`.

**Le geste immédiat**, pour rallumer le poste tout de suite, depuis une invite en
administrateur :

```
& "C:\Program Files\OpenScale\openscale.exe" service start
```

**La réparation durable** : relancez `install.ps1` **en administrateur**. Il repose
l'enregistrement du service avec le réglage manquant, et le bouton fonctionne ensuite.

> **Une simple mise à jour ne répare PAS ce poste** : elle remplace le binaire, alors que
> le réglage fautif est enregistré dans Windows, pas dans le programme. Il faut passer par
> l'installeur.

Les postes installés à partir du 10/08/2026 n'ont pas ce défaut.

**3. Redémarrer l'ordinateur.** Le bouton rouge, en dernier recours. Il demande une
confirmation, puis affiche un décompte de trente secondes que **« Annuler » arrête**.
Comptez ensuite une minute avant que l'écran revienne.
Expand Down
60 changes: 50 additions & 10 deletions cmd/openscale/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import (
// sentences their refusals share. The two actions that only READ the file are in
// configread.go, the three that REWRITE it in configwrite.go.

// runConfig is `openscale config validate|export|fingerprint|password|recovery-code`
// runConfig is `openscale config validate|export|fingerprint|station|password|recovery-code`
// (§15.1, §14.4).
//
// # Why `import` is still not here, and why `password` now is
// # Why `import` is still not here, and why `password` and `station` now are
//
// `import` is a gesture of the ADMINISTRATION SCREEN, and giving it a second home would
// give the station two ways of being reconfigured — one of them with no diff preview, no
Expand All @@ -36,24 +36,39 @@ import (
// `recovery-code` is the other half of that line: §14.4 has the eight characters
// « générés à l'installation, imprimés sur la fiche », and install.ps1 has no way to
// produce an argon2id hash on its own.
//
// `station` is the same argument applied to the three fields the installer is the only
// one in a position to know: a number, a name, and whether there is a scale at all. They
// carry NO secret, so unlike the password they may travel as options — and nothing else
// here may, which is why no action of `config` declares an option that would take one.
func runConfig(args []string, in io.Reader, out io.Writer) error {
fs := flag.NewFlagSet("config", flag.ContinueOnError)
fs.SetOutput(out)
var (
hardware = fs.Bool("hardware", false,
"conserver le bloc matériel dans l'export (par défaut il est retiré)")
output = fs.String("output", "", "fichier de sortie de l'export ; sinon la sortie standard")
output = fs.String("output", "", "fichier de sortie de l'export ; sinon la sortie standard")
number = fs.Int("number", 0, "numéro de ce poste dans la coopérative")
name = fs.String("name", "", "nom de ce poste, celui que lisent les bénévoles")
noScale = fs.Bool("no-scale", false,
"déclarer que ce poste n'a pas encore de balance")
)
fs.Usage = func() { fmt.Fprint(out, configUsage) }

positional, err := parseMixed(fs, args)
if err != nil {
return err
}
// WHICH options were typed, and not which ones carry a value: --number 0 and no
// --number at all are the same integer, and one of them is an order to write a station
// number the controls refuse while the other is an order to leave the field alone.
given := make(map[string]bool, 3)
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })

if len(positional) == 0 {
fs.Usage()
return errors.New("config prend une action : validate, export, fingerprint, migrate, " +
"password ou recovery-code")
"station, password ou recovery-code")
}

action := positional[0]
Expand Down Expand Up @@ -90,14 +105,23 @@ func runConfig(args []string, in io.Reader, out io.Writer) error {
return nil
case "migrate":
return migrateConfig(out, path)
case "station":
edit := stationEdit{DisableScale: *noScale}
if given["number"] {
edit.Number = number
}
if given["name"] {
edit.Name = name
}
return setStationIdentity(out, path, edit)
case "password":
return setAdminPassword(in, out, path)
case "recovery-code":
return mintRecoveryCode(out, path)
}
fs.Usage()
return fmt.Errorf("action inconnue %q : validate, export, fingerprint, migrate, password "+
"ou recovery-code", action)
return fmt.Errorf("action inconnue %q : validate, export, fingerprint, migrate, station, "+
"password ou recovery-code", action)
}

const configUsage = `Usage : openscale config <action> [fichier] [options]
Expand All @@ -109,6 +133,7 @@ Actions :
export écrit la configuration à cloner vers les autres postes
fingerprint affiche l'empreinte de 8 caractères des réglages partagés
migrate remet le fichier à la forme que ce binaire lit, et dit ce qu'il change
station pose l'identité du poste et l'état de la balance
password pose le mot de passe d'administration, lu sur l'entrée standard
recovery-code tire le code de secours de 8 caractères et l'affiche UNE fois

Expand All @@ -118,13 +143,28 @@ Options d'export :
c'est ce qu'un poste cloné ne doit pas hériter
--output <fichier> écrire dans un fichier plutôt que sur la sortie standard

Le mot de passe d'administration ne sort JAMAIS, avec ou sans --hardware.
Options de station (au moins une, sinon la commande ne change rien) :
--number <n> le numéro de ce poste dans la coopérative. C'est de lui que
dérive le nom du fichier de catalogue surveillé, flv_<n>.csv
--name <texte> le nom que lisent les bénévoles, « Poste 2 — fruits »
--no-scale déclarer que ce poste n'a PAS de balance : c'est l'état d'un
poste neuf, dont la balance n'est pas encore branchée ni
détectée. Elle se remet en service depuis l'écran
d'administration, page « Matériel » : « Détecter
automatiquement », puis « Utiliser cette balance » sur le
port qui a répondu

AUCUNE option ne prend de secret, ici ni ailleurs : un argument de ligne de commande se
lit dans la liste des processus, par n'importe quel utilisateur de la machine. Le mot de
passe d'administration se pose donc par « password », qui le lit sur l'entrée standard.
Il ne sort JAMAIS d'un export, avec ou sans --hardware.

Importer une configuration se fait depuis l'écran d'administration : l'aperçu du diff
champ par champ et la confirmation de 60 secondes en font partie.

migrate, password et recovery-code écrivent le FICHIER, que le poste ne relit qu'au
démarrage : arrêtez le service, lancez la commande, redémarrez-le. Le terminal affiche ce
qui est tapé — c'est une console de poste, pas un poste de travail partagé.
migrate, station, password et recovery-code écrivent le FICHIER, que le poste ne relit
qu'au démarrage : arrêtez le service, lancez la commande, redémarrez-le. Le terminal
affiche ce qui est tapé — c'est une console de poste, pas un poste de travail partagé.
`

// refuseWhatWasNotRead stops an action that would ANSWER ABOUT a file this binary did not
Expand Down
38 changes: 35 additions & 3 deletions cmd/openscale/configread_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,19 @@ func TestCloningAStationShowsTheSAMEEightCharacters(t *testing.T) {
t.Fatalf("export : %v", err)
}

// The address is checked ON THE FILE, and it has to be: what a clone must not inherit
// is what the file SAYS. Reading it back through domain.Config no longer shows it —
// `Config.UnmarshalJSON` poses the neutral loopback on an empty address, so that a
// station installed from this very file can still be administered — and an assertion
// on the decoded structure would from then on be asserting the decoder's repair
// instead of the export's discretion, which is the opposite guarantee.
if listen := listenSpelledIn(t, export); listen != "" {
t.Fatalf("le fichier exporté porte l'adresse %q : c'est l'une des choses qu'un "+
"clone ne doit pas hériter", listen)
}
exported := readJSONConfig(t, export)
if exported.Station.Number != 0 || exported.Network.Listen != "" {
t.Fatalf("l'export porte encore le poste n° %d et l'adresse %q : ce sont les deux "+
"choses qu'un clone ne doit pas hériter", exported.Station.Number, exported.Network.Listen)
if exported.Station.Number != 0 {
t.Fatalf("l'export porte encore le poste n° %d", exported.Station.Number)
}
if exported.Admin.PasswordHash != "" || exported.Admin.RecoveryCodeHash != "" {
t.Fatal("l'export porte un secret d'administration")
Expand Down Expand Up @@ -88,6 +97,29 @@ func TestCloningAStationShowsTheSAMEEightCharacters(t *testing.T) {
}
}

// listenSpelledIn reports what network.listen SAYS in a configuration file, before any
// decoder has had the chance to fill it in.
//
// A shape of its own and not domain.Config, so that nothing this reads can be repaired on
// the way: the guarantee is about the bytes a volunteer carries from one station to
// another on a USB stick.
func listenSpelledIn(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("lecture de %s : %v", path, err)
}
var document struct {
Network struct {
Listen string `json:"listen"`
} `json:"network"`
}
if err := json.Unmarshal(raw, &document); err != nil {
t.Fatalf("%s illisible : %v", path, err)
}
return document.Network.Listen
}

// TestOneBusinessSettingApartAndTheFingerprintDiverges is the other half of the check:
// an eight-character digest that never moved would be a green light nobody can trust.
//
Expand Down
Loading