docs

CLI

Use @unhandled/cli to declare deployments and upload source maps from CI, with its commands, flags, environment variables and exit codes.

@unhandled/cli is a command line client for CI pipelines. unhandled deploy records a deployment, and unhandled sourcemaps upload stores a build's source maps for a release. It needs Node 24 or later, and its only runtime dependency is @unhandled/protocol.

Install

  1. Add the package

    npm install --save-dev @unhandled/cli
  2. Run the binary

    The package installs an unhandled binary. In CI, call it through npx --no-install unhandled or a package script.

    Terminal
    npx --no-install unhandled --help
  3. Store the credentials

    Both commands need a project deploy key in UNHANDLED_API_KEY. sourcemaps upload also needs the service DSN in UNHANDLED_DSN, which tells it the service. Keep both in your CI secrets.

    Both commands send to the Unhandled API at https://unhandled.net. On a self-hosted instance, set UNHANDLED_URL to your APP_URL. The DSN host is the ingest receiver, which accepts telemetry only, so the CLI never sends to it.

Exit codes

The CLI avoids failing your pipeline unless you ask it to.

SituationExit code
Success0
A soft failure (listed below)0, with a warning: line on stderr
A soft failure with --strict1
A usage error (listed below)1, with the usage text

Soft failures are missing credentials, a failed request, an unexpected response, no version and no commit, and no .map files.

Usage errors are an unknown command or flag, a missing --service or --environment, no release for sourcemaps upload, an invalid --status, timestamp, URL or DSN, a source map path that is not a directory, and a flag with an empty value.

Pass --strict when the job should fail if the deployment marker or the upload fails.

unhandled deploy

Terminal
unhandled deploy --service api --environment production --version 2.4.0

deploy sends one POST to <url>/api/v1/deployments, with a project deploy key in x-api-key. Deploy keys start with dk_, and the endpoint rejects a DSN key. The command reads the commit, actor, CI run URL, repository URL and an external id from the CI environment. Outside CI it reads the commit from the local .git directory without running git, and generates a new external id (local:<uuid>) on every call.

The detected commit goes out as commitSha, never as version. With no version and no commit, the command warns and sends nothing.

--service <name>stringrequired

The service the deployment belongs to.

--environment <slug>stringdefault UNHANDLED_ENVIRONMENT

Required unless the variable is set.

--version <version>stringdefault UNHANDLED_VERSION

The released version.

--commit <sha>stringdefault CI variables, then local .git

The released commit.

--status <status>stringdefault succeeded

started, succeeded, failed or canceled.

--started-at <time>string

ISO 8601 with Z or an offset, such as 2026-10-21T09:30:00+02:00.

--finished-at <time>string

Same format as --started-at.

--external-id <id>stringdefault derived from the CI run

Calls that share an id update one deployment.

--actor <actor>stringdefault the CI user who started the run

Who deployed.

--ci-url <url>stringdefault the CI run URL

Link to the run.

--api-key <key>stringdefault UNHANDLED_API_KEY

The project deploy key.

--url <url>stringdefault UNHANDLED_URL, then https://unhandled.net

API base URL.

--strictbooleandefault false

Turns warnings into exit code 1.

--dry-runbooleandefault false

Prints the payload and the source of each field. Needs no API key.

Explicit flags always win over detected values. The repository URL has no flag and comes only from CI.

On success the command prints one line with the deployment id, its status, whether Unhandled merged it into an existing deployment, and a link when the server returns one. Deployments from the CLI explains how Unhandled uses these fields, including the started and finished markers.

CI detection

The CLI recognizes six CI providers from their environment variables.

ProviderDetected byCommitExternal id built from
GitHub ActionsGITHUB_ACTIONS=trueGITHUB_SHArepository, run id, run attempt
GitLab CIGITLAB_CI=trueCI_COMMIT_SHAproject path, job id
CircleCICIRCLECI=trueCIRCLE_SHA1workflow id
BuildkiteBUILDKITE=trueBUILDKITE_COMMITpipeline slug, build id, job id
Bitbucket PipelinesBITBUCKET_BUILD_NUMBER setBITBUCKET_COMMITpipeline UUID, step run number
Cloudflare Workers BuildsWORKERS_CI=1WORKERS_CI_COMMIT_SHAbuild UUID

The external id also includes the service and the environment. Calls from one CI run share an id, so a started call and a later succeeded call update the same deployment. A GitHub Actions rerun has a new attempt number and creates a new deployment. When two calls would derive different ids, for example from two GitLab jobs, pass the same --external-id to both.

Dry run

--dry-run resolves every field, prints each with its source, and exits without sending a request:

dry run: no request sent
POST https://unhandled.net/api/v1/deployments

service      api                                       flag
environment  production                                env:UNHANDLED_ENVIRONMENT
version      2.4.0                                     flag
commitSha    3f9c2a7d41b08e5f6a1c9d2e7b4f8a0c5d3e1b29  ci:github
status       succeeded                                 default
externalId   github:acme/shop:9214:1:api:production    ci:github
actor        octo-dev                                  ci:github

The source is one of flag, env:<NAME>, ci:<provider>, git, default or generated. With no version and no commit, a dry run still warns, and exits 1 under --strict.

Requests and retries

Each request times out after 5 seconds, except a source map batch, which gets 30 seconds plus 10 seconds per MB it carries. After a network error, a timeout or a 5xx response, the CLI tries once more. It does not retry a 4xx response. The warning prints the response message, any validation errors and, when the server sends one, the list of valid environments.

unhandled sourcemaps upload

Terminal
unhandled sourcemaps upload ./dist --release 2.4.0

The command collects every *.map file under the directory, recursively, and sends them in one multipart POST to <url>/api/v1/sourcemaps. It authenticates with a project deploy key in x-api-key and sends the service id from the DSN in x-unhandled-service-id. The service must belong to the deploy key's project. DSN keys ship inside browser bundles, so the endpoint refuses them. File names are relative to the directory, with / separators, so dist/assets/app.js.map is stored as assets/app.js.map.

<dir>pathrequired

Exactly one directory.

--release <release>stringdefault detected

The release the maps belong to. Without the flag: UNHANDLED_VERSION, then platform commit variables, then CI commit variables, then the local .git commit. That is the order the browser SDK's Vite plugin uses, so the maps match the bundle.

--api-key <key>stringdefault UNHANDLED_API_KEY

A project deploy key. Deploy keys start with dk_.

--dsn <dsn>stringdefault UNHANDLED_DSN

Supplies the service id. Its API key and host are not used.

--url <url>stringdefault UNHANDLED_URL, then https://unhandled.net

API base URL.

--strictbooleandefault false

Turns warnings into exit code 1.

--dry-runbooleandefault false

Lists the endpoint, the release and its source, and each file with its size.

--delete-after-uploadbooleandefault false

Deletes the maps it found afterwards, even when the upload fails or is skipped, so they never ship. A dry run deletes nothing.

The server takes 1 to 500 files and 20 MB per request, so the CLI splits a larger build into batches of at most 500 maps and 8 MB and sends them one after another. A project stores up to 1 GB of maps. An upload that would go past 1 GB is refused with 413. Uploading a file name again for the same release replaces the stored map, and only the difference counts toward the 1 GB. Each deploy key can make 60 requests a minute, shared with deploy. No .map files, a missing DSN and a missing deploy key are all warnings. A DSN that does not parse is a usage error.

Environment variables

An empty variable counts as unset.

UNHANDLED_API_KEYstring

Project deploy key for deploy and sourcemaps upload.

UNHANDLED_ENVIRONMENTstring

Default --environment for deploy.

UNHANDLED_VERSIONstring

Default --version for deploy.

UNHANDLED_URLstring

Default --url for deploy and sourcemaps upload. Set it to your APP_URL when self-hosting.

UNHANDLED_DSNstring

Default --dsn for sourcemaps upload.

GitHub Actions example

This workflow builds a storefront, uploads its source maps and brackets the rollout with deployment markers.

.github/workflows/deploy-storefront.yml
name: deploy-storefront
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      UNHANDLED_API_KEY: ${{ secrets.UNHANDLED_API_KEY }}
      UNHANDLED_DSN: ${{ secrets.UNHANDLED_STOREFRONT_DSN }}
      UNHANDLED_ENVIRONMENT: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npm run build
      - run: npx --no-install unhandled sourcemaps upload ./dist --release ${{ github.sha }}
      - run: npx --no-install unhandled deploy --service storefront --status started
      - id: rollout
        run: npm run release
      - if: success()
        run: npx --no-install unhandled deploy --service storefront --status succeeded
      - if: failure() && steps.rollout.outcome == 'failure'
        run: npx --no-install unhandled deploy --service storefront --status failed