Skip to content
← Tous les articles
· Delta1 Labs LicencesCLICI/CDGuide

Licencier un outil CLI .NET : activation sans interface, KEYRIGHT_LICENSE et des runners sans boîte de dialogue « Enregistrer »

Un outil en ligne de commande n'a pas d'écran de paramètres, s'exécute souvent sur une machine devant laquelle personne n'est assis, et est piloté par des scripts qui ne lisent que des codes de sortie. Cela casse le scénario de licence des applications de bureau — coller une clé dans une boîte de dialogue, mettre en cache un bail, continuer. Voici comment licencier un outil dotnet ou une tâche de build avec Keyright sans interface : une sous-commande `license` qui active et rend compte, les six emplacements où le SDK cherche une licence et l'idiome CLI qui correspond à chacun, un verrouillage fermé par défaut qui n'invite jamais à saisir quoi que ce soit lorsque stdin n'est pas un terminal, et le problème des runners CI éphémères résolu avec un fichier de licence hors ligne transporté dans KEYRIGHT_LICENSE plutôt qu'un siège par job.

Les conseils en matière de licences sont écrits pour des applications dotées d’une fenêtre. Collez une clé dans une boîte de dialogue, l’application appelle ActivateAsync, un bail atterrit dans app-data, et Validate() maintient l’application licenciée hors ligne jusqu’à ce que le bail doive être rafraîchi. Un outil CLI — un dotnet tool, une tâche de build, un générateur de code, un script de déploiement — rompt d’un coup trois hypothèses de ce scénario :

  • Il n’y a pas de boîte de dialogue. La seule surface de saisie est constituée des arguments, des variables d’environnement et des fichiers.
  • La machine n’a souvent aucun humain devant elle. Un runner CI, un conteneur, un job planifié.
  • L’appelant est un script qui lit des codes de sortie et stderr, pas une personne qui lit une boîte de message.

Rien de tout cela ne change la manière dont Keyright vérifie une licence — même document signé, même clé publique embarquée, même Validate() hors ligne. Ce qui change, c’est d’où la licence provient et comment l’outil en rend compte. Ce billet construit cette couche pour un outil nommé mytool.

D’où vient la licence d’une CLI

Le SDK Keyright résout une licence à partir de plusieurs sources dans un ordre de priorité fixe, de la plus haute à la plus basse : un LicenseString explicite, un LicenseFilePath explicite, le fichier désigné par la variable d’environnement KEYRIGHT_LICENSE, un ConfigLicensePath, l’emplacement app-data du système d’exploitation ({LocalApplicationData}/Keyright/{product}/license.json), et enfin le bail d’activation mis en cache, écrit par un précédent ActivateAsync. Une application de bureau ne définit aucune de ces sources. Une CLI fait correspondre chacune d’elles à un idiome que ses utilisateurs connaissent déjà :

Resolution order (highest first)--license path.jsonLicenseFilePath — one run, one fileKEYRIGHT_LICENSE=path.jsonenv var — CI, containers, scriptsapp-data/Keyright/mytool/license.jsona file the user dropped in placecached leasewritten by mytool license activateCI runnersecret → file → env varWorkstationactivate once, onlineValidate()offline, never throws
  • --license <path> sur la ligne de commande correspond à LicenseFilePath. Il l’emporte sur tout le reste, ce qui est exactement ce que vous voulez pour « exécuter cette seule invocation avec ce fichier ».
  • KEYRIGHT_LICENSE est le chemin par variable d’environnement. C’est l’idiome de la CI et des conteneurs : celui qui provisionne le job écrit un fichier et exporte la variable ; l’outil lui-même ne fait rien de particulier.
  • L’emplacement app-data est l’endroit où un utilisateur peut déposer un fichier de licence à la main — utile pour le client en environnement isolé qui a reçu license.json par e-mail.
  • Le bail mis en cache est ce que mytool license activate produit sur un poste de développeur. C’est le chemin en ligne, et après cette unique activation, il est aussi hors ligne que les autres.

Initialisez le client une seule fois et laissez l’indicateur alimenter l’option lorsqu’il est présent :

using Keyright.Client;

static KeyrightClient CreateClient(string? licensePath) =>
    KeyrightClient.Initialize(new KeyrightOptions
    {
        Product         = "mytool",
        PublicKeyBase64 = "MIIBIjANBgkq...",                  // verify-only; safe to embed
        ServiceUrl      = "https://licensing.acme.example",   // your issuing service
        LicenseFilePath = licensePath,                        // --license, or null to fall through
    });

PublicKeyBase64 est le seul matériel de clé présent dans le binaire et il ne peut que vérifier — il n’y a rien à voler dans une CLI décompilée qui permettrait à quelqu’un d’émettre une licence.

La sous-commande license

Tout ce que faisait une boîte de dialogue devient un verbe. Avec System.CommandLine :

var licenseOpt = new Option<string?>("--license", "Path to a license file for this run");

var activate = new Command("activate", "Bind this machine to a license key");
var keyArg   = new Argument<string>("key");
activate.AddArgument(keyArg);
activate.SetHandler(async (string key, string? path) =>
{
    var client = CreateClient(path);
    LicenseInfo info = await client.ActivateAsync(key, CancellationToken.None);

    if (info.IsValid && info.IsPaid)
    {
        Console.WriteLine($"Activated: {info.StatusBadge}. Lease cached; the tool now runs offline.");
        return;
    }
    Console.Error.WriteLine($"mytool: activation failed — {info.Message}");
    Environment.ExitCode = info.Status == LicenseStatus.SeatLimit ? 3 : 2;
}, keyArg, licenseOpt);

var status = new Command("status", "Show the resolved license and where it came from");
status.SetHandler((string? path) =>
{
    var client = CreateClient(path);
    LicenseInfo info = client.Validate(out LicenseSourceKind source);
    Console.WriteLine($"edition : {info.StatusBadge}");
    Console.WriteLine($"status  : {info.Status}");
    Console.WriteLine($"source  : {source}");
    if (info.IsValid) Console.WriteLine($"expires : {info.ExpiryUtc:u}");
    else              Console.WriteLine($"reason  : {info.Message}");
    Environment.ExitCode = info.IsValid ? 0 : 2;
}, licenseOpt);

var license = new Command("license") { activate, status };
license.AddGlobalOption(licenseOpt);

ActivateAsync envoie la clé accompagnée d’un identifiant de machine stable à votre service, qui consomme un siège et renvoie un bail signé que le SDK vérifie et met en cache. Il ne lève pas d’exception pour les échecs ordinaires — clé invalide, limite de sièges, révocation, service injoignable — il renvoie un LicenseInfo fermé par défaut dont Status et Message expliquent pourquoi. C’est exactement ce que veut une CLI : une valeur à traduire en code de sortie plutôt qu’une exception à intercepter.

status est la pièce dont les applications de bureau ont rarement besoin et dont les CLI ne peuvent se passer. Validate(out LicenseSourceKind source) vous indique quelle source l’a emporté, de sorte que lorsqu’un utilisateur dit « c’est licencié sur ma machine mais pas dans le pipeline », les deux sorties rendent la différence évidente : l’une a résolu le bail mis en cache, l’autre n’a rien résolu parce que KEYRIGHT_LICENSE n’avait pas été exportée.

Documentez les codes de sortie à côté des commandes. Une convention qui a fait ses preuves : 0 licencié, 2 aucune licence valide, 3 limite de sièges, 4 licencié mais le droit requis par cette commande est absent. Les scripts branchent dessus ; les humains lisent la ligne unique sur stderr.

Verrouiller une commande sans jamais inviter à saisir

Le verrou proprement dit est le même que partout ailleurs — Validate(), hors ligne, fermé par défaut — mais le compte rendu doit savoir qui écoute :

static int RequireEntitlement(KeyrightClient client, string entitlement)
{
    LicenseInfo info = client.Validate();                // offline; never throws
    if (info.IsValid && info.Entitlements.IsEnabled(entitlement))
        return 0;

    bool interactive = !Console.IsInputRedirected
                       && Environment.GetEnvironmentVariable("CI") is null;

    string fix = info.IsValid
        ? $"this command needs the '{entitlement}' entitlement — upgrade at https://acme.example/pricing"
        : "no valid license — run `mytool license activate <key>` or set KEYRIGHT_LICENSE";

    Console.Error.WriteLine($"mytool: {fix}");
    if (!interactive) return info.IsValid ? 4 : 2;

    // Only a human gets a second line; still no modal, still no blocking prompt.
    Console.Error.WriteLine("mytool: run `mytool license status` for details");
    return info.IsValid ? 4 : 2;
}

// in the command handler
int gate = RequireEntitlement(client, "parallel-build");
if (gate != 0) return gate;

IsEnabled est fermé par défaut — un droit inconnu est lu comme désactivé — si bien qu’une faute de frappe dans le nom du droit produit une commande bloquée, jamais une commande accidentellement gratuite. Les limites numériques fonctionnent de la même façon : client.GetLimit("max-projects", fallback: 1) renvoie votre valeur de repli conservatrice pour un appelant sans licence et un maximum pour "unlimited".

Ne bloquez jamais sur Console.ReadLine() pour demander une clé lorsque la licence est absente. Dans une session non interactive, cela suspend le job jusqu’à ce que le délai d’expiration du pipeline le tue, ce qui transforme un clair « sans licence » en un mystérieux « le build a duré 60 minutes et est mort ». La vérification de Console.IsInputRedirected plus la variable CI (définie par tous les grands systèmes de CI) est une assurance peu coûteuse.

Le problème des runners éphémères

Voici le piège propre aux outils qui s’exécutent en CI. L’activation en ligne est liée au nœud : le SDK prend l’empreinte de la machine, le service associe un siège à cette empreinte, et réactiver la même machine est idempotent. Sur un poste de développeur, c’est l’idéal — un siège, une machine, pour toujours. Sur un runner CI hébergé, la « machine » est une VM ou un conteneur neuf pour chaque job, si bien que chaque job présente une nouvelle empreinte, chaque job consomme un nouveau siège, et après N jobs la licence atteint sa limite de sièges et votre build échoue avec SeatLimit à 3 heures du matin.

La solution n’est pas un plus grand nombre de sièges. C’est d’utiliser le mode de livraison qui n’a pas de suivi de sièges : un fichier de licence signé autonome. Vous le signez une fois pour le client (ou pour votre propre CI, si l’outil est interne) :

keyright license --sign --licensee "Acme Corp" --product mytool --tier pro \
  --private-key mytool.private.json --expiry 2027-12-31 --out mytool.license.json

Le client stocke ce fichier dans le coffre de secrets de sa CI et le matérialise à chaque job. Dans GitHub Actions :

- name: Install license
  run: |
    printf '%s' "$MYTOOL_LICENSE_JSON" > "$RUNNER_TEMP/mytool.license.json"
    echo "KEYRIGHT_LICENSE=$RUNNER_TEMP/mytool.license.json" >> "$GITHUB_ENV"
  env:
    MYTOOL_LICENSE_JSON: ${{ secrets.MYTOOL_LICENSE_JSON }}

- name: Build
  run: mytool build --parallel        # Validate() finds KEYRIGHT_LICENSE, verifies offline

Pas d’appel d’activation, pas de réseau, pas de siège. Validate() vérifie la signature RSA du fichier avec la clé publique embarquée, contrôle le produit, l’expiration et la liste de révocation optionnelle livrée avec l’outil, et la commande s’exécute. Le fichier est le droit propre du client — il n’accorde rien au-delà de ce qu’il a acheté — mais il est transférable, il a donc sa place dans un stockage de secrets, pas dans le dépôt.

Ce que vous abandonnez par rapport à l’activation en ligne, c’est la révocation en direct. Un fichier autonome ne peut être coupé que par une liste de révocation que vous livrez avec la prochaine version de l’outil, et le décompte par siège n’est pas appliqué. Pour un outil de build, ce compromis est presque toujours le bon : la date d’expiration du fichier borne l’exposition, et vous n’alliez de toute façon pas compter les jobs CI comme des sièges.

Réservez l’activation en ligne aux machines où un siège signifie réellement une machine : les postes de développeurs, et les runners auto-hébergés persistants qui conservent leur empreinte entre les jobs. Ceux-là exécutent mytool license activate une fois ; réactiver lors d’exécutions ultérieures, lorsque le réseau est joignable, rafraîchit silencieusement le bail et ne consomme jamais de second siège, de sorte que l’outil peut appeler ActivateAsync de manière opportuniste au démarrage et se rabattre sur le bail mis en cache lorsqu’il est hors ligne.

Un outil, deux types de machine

Assemblez les pièces et l’outil dispose d’une politique unique et simple :

  • Résolution : --license l’emporte sur KEYRIGHT_LICENSE, qui l’emporte sur le fichier app-data, qui l’emporte sur le bail mis en cache. mytool license status montre lequel a gagné.
  • Postes de travail : mytool license activate <key> une fois ; siège lié au nœud ; hors ligne ensuite ; rafraîchissement opportuniste.
  • Runners jetables : un fichier de licence signé issu du coffre de secrets via KEYRIGHT_LICENSE ; pas de siège, pas de réseau, révoqué par liste, borné par l’expiration.
  • Verrouillage : Validate() + IsEnabled, fermé par défaut, aucune invite, codes de sortie documentés, une ligne sur stderr nommant le correctif.

La vérification sous-jacente n’a jamais changé. Vos utilisateurs ont simplement cessé d’avoir besoin d’une fenêtre pour l’atteindre. Voir licences hors ligne et en environnement isolé pour le chemin du fichier de licence en détail, cycle de vie de l’activation pour le modèle de bail piloté par le verbe activate, et licencier des services conteneurisés pour le problème voisin des réplicas autoscalés.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.