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
Add the package
npm install --save-dev @unhandled/clipnpm add --save-dev @unhandled/cliyarn add --dev @unhandled/cliRun the binary
The package installs an
unhandledbinary. In CI, call it throughnpx --no-install unhandledor a package script.npx --no-install unhandled --helpStore the credentials
Both commands need a project deploy key in
UNHANDLED_API_KEY.sourcemaps uploadalso needs the service DSN inUNHANDLED_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, setUNHANDLED_URLto yourAPP_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.
| Situation | Exit code |
|---|---|
| Success | 0 |
| A soft failure (listed below) | 0, with a warning: line on stderr |
A soft failure with --strict | 1 |
| 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
unhandled deploy --service api --environment production --version 2.4.0deploy 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>stringrequiredThe service the deployment belongs to.
--environment <slug>stringdefaultUNHANDLED_ENVIRONMENTRequired unless the variable is set.
--version <version>stringdefaultUNHANDLED_VERSIONThe released version.
--commit <sha>stringdefaultCI variables, then local .gitThe released commit.
--status <status>stringdefaultsucceededstarted,succeeded,failedorcanceled.--started-at <time>stringISO 8601 with
Zor an offset, such as2026-10-21T09:30:00+02:00.--finished-at <time>stringSame format as
--started-at.--external-id <id>stringdefaultderived from the CI runCalls that share an id update one deployment.
--actor <actor>stringdefaultthe CI user who started the runWho deployed.
--ci-url <url>stringdefaultthe CI run URLLink to the run.
--api-key <key>stringdefaultUNHANDLED_API_KEYThe project deploy key.
--url <url>stringdefaultUNHANDLED_URL, then https://unhandled.netAPI base URL.
--strictbooleandefaultfalseTurns warnings into exit code 1.
--dry-runbooleandefaultfalsePrints 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.
| Provider | Detected by | Commit | External id built from |
|---|---|---|---|
| GitHub Actions | GITHUB_ACTIONS=true | GITHUB_SHA | repository, run id, run attempt |
| GitLab CI | GITLAB_CI=true | CI_COMMIT_SHA | project path, job id |
| CircleCI | CIRCLECI=true | CIRCLE_SHA1 | workflow id |
| Buildkite | BUILDKITE=true | BUILDKITE_COMMIT | pipeline slug, build id, job id |
| Bitbucket Pipelines | BITBUCKET_BUILD_NUMBER set | BITBUCKET_COMMIT | pipeline UUID, step run number |
| Cloudflare Workers Builds | WORKERS_CI=1 | WORKERS_CI_COMMIT_SHA | build 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:githubThe 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
unhandled sourcemaps upload ./dist --release 2.4.0The 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>pathrequiredExactly one directory.
--release <release>stringdefaultdetectedThe release the maps belong to. Without the flag:
UNHANDLED_VERSION, then platform commit variables, then CI commit variables, then the local.gitcommit. That is the order the browser SDK's Vite plugin uses, so the maps match the bundle.--api-key <key>stringdefaultUNHANDLED_API_KEYA project deploy key. Deploy keys start with
dk_.--dsn <dsn>stringdefaultUNHANDLED_DSNSupplies the service id. Its API key and host are not used.
--url <url>stringdefaultUNHANDLED_URL, then https://unhandled.netAPI base URL.
--strictbooleandefaultfalseTurns warnings into exit code 1.
--dry-runbooleandefaultfalseLists the endpoint, the release and its source, and each file with its size.
--delete-after-uploadbooleandefaultfalseDeletes 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_KEYstringProject deploy key for
deployandsourcemaps upload.UNHANDLED_ENVIRONMENTstringDefault
--environmentfordeploy.UNHANDLED_VERSIONstringDefault
--versionfordeploy.UNHANDLED_URLstringDefault
--urlfordeployandsourcemaps upload. Set it to yourAPP_URLwhen self-hosting.UNHANDLED_DSNstringDefault
--dsnforsourcemaps upload.
GitHub Actions example
This workflow builds a storefront, uploads its source maps and brackets the rollout with deployment markers.
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