Skip to content
← Todas las publicaciones
· Delta1 Labs LicenciamientoCLICI/CDGuía

Licenciar una herramienta CLI de .NET: activación sin interfaz, KEYRIGHT_LICENSE y runners sin diálogo de "Registrar"

Una herramienta de línea de comandos no tiene pantalla de configuración, a menudo se ejecuta en una máquina en la que no hay nadie sentado y la dirigen scripts que solo leen códigos de salida. Eso rompe el guion del licenciamiento de escritorio — pegar una clave en un diálogo, cachear un lease y seguir adelante. Aquí tienes cómo licenciar una herramienta dotnet o una tarea de compilación con Keyright sin interfaz: un subcomando `license` que activa e informa, los seis lugares en los que el SDK busca una licencia y qué idioma de CLI corresponde a cada uno, un control de acceso fail-closed que nunca pregunta cuando stdin no es un terminal, y el problema del runner de CI efímero resuelto con un archivo de licencia offline transportado en KEYRIGHT_LICENSE en lugar de un asiento por trabajo.

Las guías de licenciamiento están escritas para aplicaciones con ventana. Pegas una clave en un diálogo, la aplicación llama a ActivateAsync, un lease aterriza en app-data y Validate() mantiene la aplicación licenciada offline hasta que el lease necesita renovarse. Una herramienta CLI — una dotnet tool, una tarea de compilación, un generador de código, un script de despliegue — rompe de golpe tres supuestos de esa historia:

  • No hay diálogo. La única superficie de entrada son los argumentos, las variables de entorno y los archivos.
  • A menudo no hay ningún humano en la máquina. Un runner de CI, un contenedor, un trabajo programado.
  • Quien llama es un script que lee códigos de salida y stderr, no una persona que lee un cuadro de mensaje.

Nada de esto cambia cómo Keyright verifica una licencia — el mismo documento firmado, la misma clave pública incrustada, el mismo Validate() offline. Lo que cambia es de dónde viene la licencia y cómo la herramienta informa sobre ella. Este artículo construye esa capa para una herramienta llamada mytool.

De dónde sale la licencia de una CLI

El SDK de Keyright resuelve una licencia a partir de varias fuentes en un orden de precedencia fijo, de mayor a menor: un LicenseString explícito, un LicenseFilePath explícito, el archivo indicado por la variable de entorno KEYRIGHT_LICENSE, un ConfigLicensePath, la ubicación de app-data del sistema operativo ({LocalApplicationData}/Keyright/{product}/license.json) y, por último, el lease de activación cacheado que escribió un ActivateAsync anterior. Una aplicación de escritorio no configura ninguna de ellas. Una CLI asigna cada una a un idioma que sus usuarios ya conocen:

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> en la línea de comandos corresponde a LicenseFilePath. Gana sobre todo lo demás, que es justo lo que quieres para “ejecuta esta única invocación con ese archivo”.
  • KEYRIGHT_LICENSE es la ruta por variable de entorno. Es el idioma de CI y contenedores: quien aprovisiona el trabajo escribe un archivo y exporta la variable; la herramienta en sí no hace nada especial.
  • La ubicación de app-data es donde un usuario puede dejar a mano un archivo de licencia — útil para el cliente con la red aislada (air-gapped) que recibió license.json por correo electrónico.
  • El lease cacheado es lo que produce mytool license activate en una estación de trabajo de desarrollador. Es la vía online y, tras esa única activación, es tan offline como las demás.

Inicializa el cliente una sola vez y deja que el flag alimente la opción cuando esté presente:

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 es el único material de clave en el binario y solo puede verificar — no hay nada que robar de una CLI decompilada que permita a alguien emitir una licencia.

El subcomando license

Todo lo que hacía un diálogo se convierte en un verbo. Con 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 envía la clave junto con un identificador de máquina estable a tu servicio, que consume un asiento y devuelve un lease firmado que el SDK verifica y cachea. No lanza excepciones para los fallos ordinarios — clave incorrecta, límite de asientos, revocada, servicio inalcanzable — sino que devuelve un LicenseInfo fail-closed cuyos Status y Message explican el porqué. Eso es exactamente lo que quiere una CLI: un valor que asignar a un código de salida en lugar de una excepción que capturar.

status es la pieza que las aplicaciones de escritorio rara vez necesitan y sin la que una CLI no puede vivir. Validate(out LicenseSourceKind source) te dice qué fuente ganó, de modo que cuando un usuario dice “en mi máquina está licenciado pero en el pipeline no”, las dos salidas hacen evidente la diferencia: una resolvió el lease cacheado y la otra no resolvió nada porque KEYRIGHT_LICENSE no se exportó.

Documenta los códigos de salida junto a los comandos. Una convención que ha funcionado bien: 0 licenciado, 2 sin licencia válida, 3 límite de asientos, 4 licenciado pero falta el entitlement que necesita este comando. Los scripts ramifican según ellos; los humanos leen la única línea de stderr.

Controlar el acceso a un comando sin preguntar nunca

El control real es el mismo que en cualquier otro sitio — Validate(), offline, fail-closed — pero el informe tiene que saber quién está escuchando:

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 falla cerrado — un entitlement desconocido se lee como deshabilitado — así que una errata en el nombre del entitlement produce un comando bloqueado, nunca uno gratuito por accidente. Los límites numéricos funcionan igual: client.GetLimit("max-projects", fallback: 1) devuelve tu valor conservador por defecto para un llamador sin licencia y un máximo para "unlimited".

Nunca te bloquees en Console.ReadLine() para pedir una clave cuando falta la licencia. En una sesión no interactiva eso deja el trabajo colgado hasta que el timeout del pipeline lo mata, lo que convierte un claro “sin licencia” en un misterioso “la compilación tardó 60 minutos y murió”. La comprobación de Console.IsInputRedirected más la variable CI (que establecen todos los sistemas de CI importantes) es un seguro barato.

El problema del runner efímero

Esta es la trampa específica de las herramientas que se ejecutan en CI. La activación online está vinculada al nodo (node-locked): el SDK toma la huella de la máquina, el servicio vincula un asiento a esa huella y reactivar la misma máquina es idempotente. En una estación de trabajo de desarrollador eso es ideal — un asiento, una máquina, para siempre. En un runner de CI alojado, la “máquina” es una VM o un contenedor nuevos para cada trabajo, así que cada trabajo presenta una huella nueva, cada trabajo consume un asiento nuevo y, tras N trabajos, la licencia alcanza su límite de asientos y tu compilación falla con SeatLimit a las 3 de la madrugada.

La solución no es un número de asientos mayor. Es usar la vía de entrega que no tiene seguimiento de asientos: un archivo de licencia firmado independiente. Lo firmas una vez para el cliente (o para tu propio CI, si la herramienta es interna):

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

El cliente guarda ese archivo en el almacén de secretos de su CI y lo materializa en cada trabajo. En 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

Sin llamada de activación, sin red, sin asiento. Validate() verifica la firma RSA del archivo contra la clave pública incrustada, comprueba el producto, la caducidad y la lista de revocación opcional distribuida, y el comando se ejecuta. El archivo es el entitlement propio del cliente — no concede nada más allá de lo que compró — pero es transferible, así que su sitio es el almacén de secretos, no el repositorio.

A lo que renuncias frente a la activación online es a la revocación en vivo. Un archivo independiente solo puede cortarse mediante una lista de revocación que distribuyas con la siguiente versión de la herramienta, y no se aplica un recuento por asiento. Para una herramienta de compilación ese intercambio casi siempre es el correcto: la fecha de caducidad del archivo acota la exposición, y de todos modos no ibas a contar los trabajos de CI como asientos.

Reserva la activación online para las máquinas en las que un asiento realmente significa una máquina: estaciones de trabajo de desarrolladores y runners autoalojados persistentes que conservan su huella entre trabajos. Esas ejecutan mytool license activate una vez; reactivar en ejecuciones posteriores, cuando la red es alcanzable, renueva el lease silenciosamente y nunca consume un segundo asiento, así que la herramienta puede llamar a ActivateAsync de forma oportunista al arrancar y recurrir al lease cacheado cuando esté offline.

Una herramienta, dos tipos de máquina

Junta las piezas y la herramienta tiene una política única y sencilla:

  • Resolución: --license gana a KEYRIGHT_LICENSE, que gana al archivo de app-data, que gana al lease cacheado. mytool license status muestra cuál ganó.
  • Estaciones de trabajo: mytool license activate <key> una vez; asiento vinculado al nodo; offline después; renovación oportunista.
  • Runners desechables: un archivo de licencia firmado desde el almacén de secretos vía KEYRIGHT_LICENSE; sin asiento, sin red, revocado por lista, acotado por la caducidad.
  • Control de acceso: Validate() + IsEnabled, fail-closed, sin preguntas, códigos de salida documentados, una línea en stderr que nombra la solución.

La verificación subyacente nunca cambió. Simplemente tus usuarios dejaron de necesitar una ventana para llegar a ella. Consulta licenciamiento offline y en entornos aislados para la vía del archivo de licencia en profundidad, ciclo de vida de la activación para el modelo de lease que impulsa el verbo activate, y licenciar servicios .NET en contenedores para el problema relacionado de las réplicas autoescaladas.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.