# Cohorly CLI

`@cohorly/cli` uploads the files Cohorly needs to symbolicate errors: source map indexes for web and React Native (including Hermes), R8 mapping files for Android and dSYM symcaches for iOS. Run it in CI after every production build, or let its Vite, Next.js and Expo plugins do it.

Errors reach Cohorly with minified or obfuscated stack frames. The CLI runs on
your build machine, turns each source map into a compact lookup index and
uploads it, so Cohorly can show the original file, line and function. Your
source code (`sourcesContent`) never leaves the build machine.

## Install

```bash
pnpm add -D @cohorly/cli
```

Node 22+. The binary is `cohorly`; `npx cohorly --help` lists the commands and
`cohorly <command> --help` documents each one.

## Environment variables

| Variable                    | Flag        | Meaning                                                                                                                                                                                         |
| --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `COHORLY_UPLOAD_TOKEN`      | `--token`   | Upload token of the project. Required for uploads; the CLI exits with code 1 before any request when it is missing, and never prints it. Owners create and revoke tokens in Settings > Symbols. |
| `COHORLY_API_URL`           | `--api-url` | Your Cohorly server. Defaults to the hosted Cohorly API, the same default as `@cohorly/node`.                                                                                                   |
| `COHORLY_UPLOAD_DISABLED`   | none        | `1` makes the React Native build steps (`react-native xcode`, `react-native gradle`) skip the upload with a warning. Nothing else skips it.                                                     |
| `COHORLY_DSYM_WAIT_SECONDS` | none        | How long `react-native xcode` waits for Xcode to finish writing the app's dSYM before failing (default 30).                                                                                     |

Keep the token in your CI secret store, never in the repository.

## Release and dist

Every upload is stored under a **release** and an optional **dist**. They must
be exactly the `$exception_release` and `$exception_dist` the SDK sends with
each error, or Cohorly will not find the files for that crash. Use the same
source for both, for example the app version plus build number
(`ios@3.8.0+412`) or the deploy commit (`web@2026.11.3` with dist `b71e04`).

## Commands

### sourcemaps inject

```bash
cohorly sourcemaps inject dist
```

For every `.js`, `.mjs` or `.cjs` file with a sibling `.map`, writes a
`//# debugId=<uuid>` comment into the bundle and a `debugId` field into the
map. The id is derived from the bundle content and its source map, so it is
stable across machines and differs when the mapping changes. Running it twice changes nothing. Run it **before** you deploy the
bundles, since it edits them.

### sourcemaps upload

```bash
cohorly sourcemaps upload --release web@2026.11.3 --dist b71e04 dist
```

Builds an index from every `.map` under the directory (`.css.map` and
`node_modules` are skipped) and uploads one index per bundle. Each is stored
under the bundle path relative to that directory, so point it at the folder
your site serves: `dist/assets/app.js` becomes `~/assets/app.js` and matches a
frame from `https://cdn.example.com/assets/app.js`.

**Hermes.** A `<name>.bundle.map` next to a `<name>.hbc.map` is composed into
one index for `<name>.bundle`. A lone `<name>.hbc.map` (for example the map
`expo export` already composed) is treated as final and is also stored as
`<name>.bundle`. For other file names, pass the pair explicitly:

```bash
cohorly sourcemaps upload --release android@3.8.1+415 \
  --packager-map build/index.android.bundle.packager.map \
  --hermes-map build/index.android.bundle.compiler.map \
  --bundle-file index.android.bundle \
  build
```

### debug-files upload

```bash
cohorly debug-files upload --release android@3.8.1+415 --dist release \
  app/build/outputs/mapping/release
```

Uploads R8 / ProGuard `mapping.txt` files unchanged. The path can be a file or
a directory searched for `mapping.txt` and `.dSYM` bundles.

**dSYMs (macOS only).** A `.dSYM`, a DWARF file inside
`X.dSYM/Contents/Resources/DWARF/`, or a directory that contains `.dSYM`s is
turned into a **symcache**: a compact lookup file with function names
(demangled), file paths, lines and inline frames, one per architecture slice
(`arm64`, `x86_64`). Only the symcache is uploaded, keyed by the slice's
`LC_UUID`; the dSYM itself never leaves the build machine.

```bash
cohorly debug-files upload --release com.example.shop@3.8.0+412 \
  build/Build/Products/Release-iphoneos
```

Building a symcache uses the Xcode toolchain (`xcrun dwarfdump`, `nm`,
`c++filt`, `swift-demangle`), so it runs on the Mac that built the app. On
any other system, or on a Mac without the Command Line Tools
(`xcode-select --install`), a path with a dSYM fails before anything is
uploaded. The same symcache is built byte for byte every time, so uploading
twice is harmless. 32-bit slices are not supported; they are reported as
failed and the other slices still upload.

## Exit codes and retries

The CLI tries every file, then prints a summary. It exits with a non-zero code
if **any** upload failed, so your build fails instead of shipping a release
Cohorly cannot symbolicate. Server errors (`5xx`) and network errors are
retried up to 3 attempts; client errors (`4xx`) are not retried. Files above
50 MiB after compression are refused.

## React Native and Expo

Add the config plugin to `app.json`, then run `npx expo prebuild` (EAS Build
does this for you):

```json
{
  "expo": {
    "plugins": ["@cohorly/cli"]
  }
}
```

It changes the native projects so every release build uploads its symbols,
with the release the React Native SDK sends
(`<bundleId>@<version>+<build>`, from the processed `Info.plist` on iOS and
`applicationId`, `versionName` and `versionCode` on Android) and no dist,
since an embedded bundle has none:

- **iOS.** The "Bundle React Native code and images" build phase exports
  `SOURCEMAP_FILE`, so the composed Hermes source map is kept. A new
  "Upload symbols to Cohorly" phase runs after it (and after the dSYM is
  written) and calls `cohorly react-native xcode`: it uploads the index of
  the bundle as `~/main.jsbundle` and every `.dSYM` of the build as
  symcaches. Debug configurations are skipped. Release configurations need
  `DEBUG_INFORMATION_FORMAT = dwarf-with-dsym` (the default). If you pass an
  explicit `release` to the SDK, set the same value in `COHORLY_RELEASE` for
  the build.
- **Android.** `android/app/build.gradle` applies `cohorly.gradle`, which
  adds an `uploadCohorlySymbols<Variant>` task to every non-debuggable
  variant. It runs after `assemble<Variant>` and `bundle<Variant>` and calls
  `cohorly react-native gradle`: the index of the bundle as
  `~/index.android.bundle` and, when the variant is minified, its R8
  `mapping.txt`. Set the Gradle property `cohorly.nodeExecutable` if `node`
  is not on the `PATH` of the build.

Set `COHORLY_UPLOAD_TOKEN` in the build environment (an EAS secret on EAS
Build). A missing token, a missing source map or dSYM, or a failed upload
**fails the build**. The only way to build without uploading is
`COHORLY_UPLOAD_DISABLED=1`, which prints a warning; use it for local release
builds you will not ship.

### EAS Update

An OTA update ships a new JS bundle for binaries that are already installed.
Export it with source maps, publish it with `--json`, then upload its maps:

```bash
eas update --branch production --message "Fix checkout" \
  --source-maps --json --non-interactive > update.json
cohorly expo upload-update --update-json update.json dist
```

For each platform in `update.json`, the command finds the bundle and its map
in `dist/_expo/static/js/<platform>/` and uploads the index as
`~/main.jsbundle` (iOS) or `~/index.android.bundle` (Android), with the
update id as the dist, exactly what the SDK sends while that update runs.
The release is computed from `npx expo config` (`ios.bundleIdentifier` or
`android.package`, `version`, and `ios.buildNumber` or `android.versionCode`),
or given with `--release-ios` and `--release-android`. Anything missing,
including a build number kept by EAS remote versioning, is an error: pass the
flags instead of letting the CLI guess.

**Limit:** the maps of an update are stored under one release per platform,
the one computed when you publish. An update that runs on binaries with
another version or build number is not symbolicated for those binaries. Pass
the flags and upload again for each release you need.

## Vite

```ts
// vite.config.ts
import { defineConfig } from "vite";
import { cohorlyVitePlugin } from "@cohorly/cli/vite";

export default defineConfig({
  plugins: [
    cohorlyVitePlugin({
      release: `web@${process.env.GIT_COMMIT_SHA}`,
    }),
  ],
});
```

At the end of `vite build`, the plugin injects debug ids into the output and
uploads one index per bundle (the same as `sourcemaps inject` plus
`sourcemaps upload` on `build.outDir`). SSR builds are skipped. When
`build.sourcemap` is off, the plugin turns it on as `"hidden"` and deletes the
`.map` files after the upload, so they are never served. `"inline"` source
maps are refused: they leave no `.map` file to upload.

## Next.js

Requires Next.js 15.4 or later (`compiler.runAfterProductionCompile`).

```js
// next.config.mjs
import { withCohorly } from "@cohorly/cli/next";

export default withCohorly(
  { reactStrictMode: true },
  { release: `web@${process.env.VERCEL_GIT_COMMIT_SHA}` },
);
```

After `next build` compiles, the wrapper injects debug ids into
`.next/static` and uploads its indexes (`~/chunks/...`, which matches the
`/_next/static/chunks/...` frames by suffix). It turns on
`productionBrowserSourceMaps` when it is off, and then deletes the maps after
the upload. Your own `runAfterProductionCompile` still runs, first.

### Plugin options

Both plugins take the same options:

| Option                        | Default                                      | Meaning                                             |
| ----------------------------- | -------------------------------------------- | --------------------------------------------------- |
| `release`                     | required                                     | Must equal the release the SDK sends.               |
| `dist`                        | none                                         | Must equal the dist the SDK sends, if any.          |
| `token`                       | `COHORLY_UPLOAD_TOKEN`                       | Upload token.                                       |
| `apiUrl`                      | `COHORLY_API_URL`, else the hosted API       | Your Cohorly server.                                |
| `disabled`                    | `false`                                      | The only way to build without uploading.            |
| `deleteSourceMapsAfterUpload` | `true` when the plugin turned source maps on | Delete every `.map` of the output after the upload. |

A failed upload or a missing token fails the build.

## CI examples

### Vercel

Run the upload as part of the build command, so a failed upload fails the
deployment:

```json
{
  "scripts": {
    "build": "vite build --sourcemap && cohorly sourcemaps inject dist && cohorly sourcemaps upload --release \"web@$VERCEL_GIT_COMMIT_SHA\" dist"
  }
}
```

Set `COHORLY_UPLOAD_TOKEN` in the project's environment variables, and make
the web SDK send the same release. Delete the `.map` files from `dist` after
the upload if you do not want to serve them.

### EAS Build (React Native)

With the [Expo plugin](#react-native-and-expo) the native builds upload their
own symbols; the script below is for projects that do not use it.

EAS runs the `eas-build-on-success` script of `package.json` after a
successful build, and a non-zero exit marks the build as failed:

```json
{
  "scripts": {
    "eas-build-on-success": "sh scripts/upload-symbols.sh"
  }
}
```

```bash
# scripts/upload-symbols.sh
set -e
# <bundleId>@<version>+<build>: same value the SDK sends
RELEASE=$(npx expo config --json --type public | node -e '
  const config = JSON.parse(require("fs").readFileSync(0, "utf8"));
  const ios = process.argv[1] === "ios";
  const app = (ios ? config.ios : config.android) ?? {};
  const id = ios ? app.bundleIdentifier : app.package;
  const build = ios ? app.buildNumber : app.versionCode;
  if (!id || !config.version || build === undefined) {
    console.error("cannot compute the release from the Expo config");
    process.exit(1);
  }
  process.stdout.write(`${id}@${config.version}+${build}`);
' "$EAS_BUILD_PLATFORM")
# SOURCEMAP_DIR: the folder your build writes the bundle's .map files to
cohorly sourcemaps upload --release "$RELEASE" "$SOURCEMAP_DIR"
if [ "$EAS_BUILD_PLATFORM" = "android" ]; then
  cohorly debug-files upload --release "$RELEASE" android/app/build/outputs/mapping/release
fi
```

Store `COHORLY_UPLOAD_TOKEN` as an EAS secret. The release is read from the
Expo config the same way the SDK builds it on the device. If you pass an
explicit `release` to `init()`, set `RELEASE` to that same value instead; with
EAS remote versioning the config has no build number, so do the same.
