All guides

How to License a Command-Line Tool for CI, Docker and SSH

Add a license key for a CLI tool with non-interactive checks, safe secret input, predictable exit codes and deliberate CI seat policies.

A license key for a CLI tool should authorize useful commands without breaking installation, CI jobs or an SSH session. Check the license when a protected operation starts, return predictable exit codes and keep credentials out of stdout. Treat registering a machine and renting a concurrent seat as separate operations.

This guide covers Node.js, Python and Go tools. The examples are small online license-check commands using PermitCore SDK 2.1.1. They are not a complete login manager or floating-seat runner.

Keep installation and basic help usable

Let package installation, --help and --version work without a license or an internet connection. A package manager may install your tool while building an image, before a customer supplies runtime secrets. A postinstall licensing prompt can hang an unattended build.

Place the gate on your paid operation, such as mytool export. Separate an explicit mytool activate setup command if the license requires device registration. An install script should neither contact your license service nor consume a seat. This is an application design choice, not a package-manager guarantee.

Resolve the key without exposing it

For an unattended job, accept a license key through an explicitly injected environment variable. For an interactive workstation, a separate login command can save it in an OS credential store. If a protected configuration file is necessary, keep it outside the repository and restrict access to the account running the tool. POSIX mode 0600 needs an equivalent access-control policy on Windows.

Define precedence: an explicit secret source for this invocation, then the user's saved credential. Avoid a raw --license-key VALUE flag: arguments can appear in process listings, shell history and audit logs. Environment variables also remain accessible to the process and suitably privileged users; they are not encryption. Never print the key, dump the whole environment or include it in crash reports.

GitHub's secrets guidance recommends avoiding command-line arguments for secrets. Scope a CI secret to the job that needs it, withhold it from untrusted fork code and use the CI system's secret masking as a second layer. Do not bake a customer license into a Docker image, Dockerfile ARG or committed compose file. Inject it when the container runs.

Use predictable exit codes and quiet output

Sample exit codeMeaningOutput
0License accepted; the application may proceed to its own entitlement checksNormal command output on stdout
2Required license or product configuration is missingActionable setup message on stderr
3License check did not pass, including an unavailable service without usable offline proofSanitized message on stderr; protected work does not start

These numbers are this guide's CLI contract, not PermitCore API status codes. A license rejection can arrive as HTTP 200 with isValid: false. Do not turn a network failure into success, and do not tell a paying customer that their key is invalid when the service could not be reached. A fuller application can map documented error codes to specific recovery messages without dumping server responses.

Keep prompts opt-in. A CI job or piped command should return a useful failure rather than wait for a person. Detecting a terminal can help your UI, but it is not a security boundary. If a command streams machine-readable data, keep licensing diagnostics on stderr.

Online license checks in Node.js, Python and Go

Install the matching SDK using the Node, Python or Go instructions. Set MYTOOL_PRODUCT_ID to the real product GUID from Admin → Products; supply that same expected product in every language. A license for another product must not unlock this tool.

MYTOOL_API_URL is optional trusted deployment configuration for a self-hosted API; production requests need HTTPS. Do not obtain the API URL from a license file, an error response or another untrusted input. The example tests use an isolated loopback HTTP fixture. None of these clients needs your administrative API key.

Save the first example as license-check.mjs. Each example waits up to five seconds per HTTP attempt, disables additional SDK retries and disables offline caching to make the online-only boundary explicit. This is a per-attempt transport limit, not a guaranteed whole-command wall-clock deadline.

Node.js

import { PermitCoreClient } from '@permitcore/permitcore';

const key = process.env.MYTOOL_LICENSE_KEY?.trim();
const productId = process.env.MYTOOL_PRODUCT_ID?.trim();
if (!key || !productId) {
  console.error('Set MYTOOL_LICENSE_KEY and MYTOOL_PRODUCT_ID.');
  process.exitCode = 2;
} else {
  const client = new PermitCoreClient({
    baseUrl: process.env.MYTOOL_API_URL || 'https://api.permitcore.dev',
    enableOfflineCache: false, timeoutMs: 5000, maxRetries: 0,
  });
  const result = await client.validate(key, '1.0.0', productId);
  if (!result.isValid) {
    console.error('License check did not pass. Check the key, product and service availability.');
    process.exitCode = 3;
  } else {
    console.log('License accepted; protected work may start.');
  }
}

Use process.exitCode so Node can flush pending output before normal termination.

Python

import os
import sys
from permitcore import PermitCoreClient

def main():
    key = os.environ.get('MYTOOL_LICENSE_KEY', '').strip()
    product_id = os.environ.get('MYTOOL_PRODUCT_ID', '').strip()
    if not key or not product_id:
        print('Set MYTOOL_LICENSE_KEY and MYTOOL_PRODUCT_ID.', file=sys.stderr)
        return 2
    client = PermitCoreClient(
        os.environ.get('MYTOOL_API_URL') or 'https://api.permitcore.dev',
        enable_offline_cache=False, timeout=5, max_retries=0,
    )
    result = client.validate(key, version='1.0.0', expected_product_id=product_id)
    if not result.is_valid:
        print('License check did not pass. Check the key, product and service availability.', file=sys.stderr)
        return 3
    print('License accepted; protected work may start.')
    return 0

if __name__ == '__main__':
    sys.exit(main())

The returned status reaches sys.exit; error messages use sys.stderr.

Go

package main

import (
    "fmt"
    "os"
    "strings"
    "time"
    permitcore "github.com/permitCore-spec/permitcore-sdk-go/v2"
)

func run() int {
    key := strings.TrimSpace(os.Getenv("MYTOOL_LICENSE_KEY"))
    productID := strings.TrimSpace(os.Getenv("MYTOOL_PRODUCT_ID"))
    if key == "" || productID == "" {
        fmt.Fprintln(os.Stderr, "Set MYTOOL_LICENSE_KEY and MYTOOL_PRODUCT_ID.")
        return 2
    }
    baseURL := os.Getenv("MYTOOL_API_URL")
    if baseURL == "" { baseURL = "https://api.permitcore.dev" }
    retries := 0
    client := permitcore.New(baseURL, permitcore.Options{
        DisableOfflineCache: true, Timeout: 5 * time.Second, MaxRetries: &retries,
    })
    result := client.Validate(key, "1.0.0", productID)
    if !result.IsValid {
        fmt.Fprintln(os.Stderr, "License check did not pass. Check the key, product and service availability.")
        return 3
    }
    fmt.Println("License accepted; protected work may start.")
    return 0
}

func main() { os.Exit(run()) }

Keep resource cleanup in run before calling os.Exit: it does not execute deferred functions in the caller. The example has no floating lease to release.

Validation does not activate a device, consume an activation-pool entry or check out a floating seat. A successful response is not a substitute for those policies. Add product-specific feature checks before protected work. The snippets deliberately print a demonstration message rather than perform your paid operation.

Choose seats for CI jobs, not for a Docker image

PolicyWhat your CLI must doCI consideration
Node-lockedExplicitly activate a stable, private device identity; validate on later runsA new runner identity can register as a new device. Do not share one fake device ID across unrelated runners.
Count-based activationTrack successful registration; avoid activating on every commandRepeated activation can consume another seat and monthly pool entry even on one machine.
Floating / concurrentCheck out a session, heartbeat it while working, and check it in afterwardsMatch purchased concurrency to simultaneous jobs; validation alone does not reserve capacity.

The monthly activation pool counts qualifying new activations, not validation requests or concurrent jobs. Same-device node-locked reactivation reuses the recorded seat; losing the persisted identity can make a reinstall a new device. API rate limits remain separate.

For floating licenses, use the returned expiresAt deadline and handle every checkout and heartbeat result. Schedule heartbeats in your application, normally every four minutes. Stop protected work on a denied renewal or missed deadline; offline validation cannot renew the lease. Attempt check-in in normal cleanup and your CI job's cleanup step, while recognizing that a hard kill may prevent cleanup. A failed or lost check-in response does not prove the seat was released. Server expiry is the backstop.

Floating availability and capacity depend on the vendor's PermitCore plan and the license configuration. Read the current guide rather than assuming unlimited CI concurrency on every plan.

Support offline jobs deliberately

The examples above are online-only. For intermittent connectivity, configure trusted tenant public keys, an appropriate signed grace period and a protected persistent cache directory through the SDK's offline licensing configuration. Verify the signed token and its product/device/deadline on every use. An editable local licensed=true file is not proof.

An ephemeral runner without a prior verified token cannot invent an offline grant. An air-gapped deployment needs a separately issued offline activation token and a way to provision its trust keys and context. Offline proof expires, cannot learn revocation instantly and does not grant a floating seat. Never copy a device-bound cache from one unrelated runner to another.

See the Python offline tutorial, signed grace limits and node-locked versus floating policy before choosing your failure mode. A customer license gates client behavior; it cannot make modifiable client code unbreakable.

Test the paid command before shipping

  • Installation, help and version work without a key, network or terminal.
  • Missing configuration fails without a prompt; a wrong-product key and expired key never start protected work.
  • Success preserves clean stdout; failures and timeouts expose no raw key or server body.
  • Repeated validation adds no activation. Test explicit activation and real floating lease lifecycle separately if used.
  • Test parallel jobs, process termination, unavailable networking and expired offline proof under the policy you actually sell.
  • Document purchased seats, offline limits, support and renewal behavior before checkout.

The three exact snippets are executed against a local HTTP contract fixture with the real SDKs. Those checks verify CLI wiring and exit behavior, not a fresh hosted payment, activation or floating-lease acceptance. Start your integration with the validation API and the Go licensing tutorial.

Put the guide into practice

Build your first licensed product without building a license server.

Create a free PermitCore account, issue a test license, and validate it from your application.