@otalan/cli
v1.4.1
Published
Otalan CLI for bundling and publishing OTA updates through otalan.com.
Downloads
2,495
Maintainers
Readme
@otalan/cli
Otalan CLI for bundling and publishing OTA update releases for Capacitor and Expo apps.
Website: otalan.com
Published as an npm package, but the CLI itself runs on Bun.
Requirements
- Bun
>= 1.3.11installed and available on yourPATH - An Otalan OTA Publish Key for commands that talk to the Otalan API
Otalan key prefixes are stable API identifiers:
- OTA Publish Key values use the
otalan_ci_prefix internally. - OTA App Key values use the
otalan_ota_prefix internally.
Do not use an OTA App Key in the CLI. OTA App Keys can be embedded in mobile app code for update checks, but they should not be shared outside the app or used for release automation.
Platform Support
The npm package ships a Bun-based CLI entrypoint, not standalone native binaries.
- macOS and Linux are supported when Bun
>= 1.3.11is installed. - Windows support is experimental until the CLI release flow is validated on Windows.
- Native compile scripts exist for macOS, Linux, and Windows maintainers, but the compiled binaries are not included in the npm package.
App Framework Support
Officially supported app targets and versions:
- Capacitor 7 and 8 with
--target capacitor - Expo SDK 54 and 55 with
--target expo
Other app targets and older framework versions may work, but they are not officially supported for the moment.
Install
Recommended:
bun add -g @otalan/cliIf you install the package with npm, pnpm, or yarn, bun still needs to be installed because the executable runs with #!/usr/bin/env bun.
Local development from this repo:
bun ./src/bin.ts helpQuick Start
Capacitor
- Log in with your OTA Publish Key:
otalan login --api-key otalan_ci_xxx- Link the current repo to your active Otalan app:
otalan initBuild your web assets with your app's normal build command.
Bundle the OTA payload:
otalan bundle --target capacitor --platform ios --bundle-id 1.0.5- Publish the release:
otalan publish --channel productionotalan bundle --target capacitor packages existing built web assets. By default it reads dist/ first, then www/; pass --input-dir <path> if your build outputs somewhere else. Your app build must run first.
otalan publish waits for server-side validation to finish before it returns.
Expo
- Log in with your OTA Publish Key:
otalan login --api-key otalan_ci_xxx- Link the current repo to your active Otalan app:
otalan init- Bundle the OTA payload:
otalan bundle --target expo --platform ios --bundle-id 1.0.5- Publish the release:
otalan publish --channel productionotalan bundle --target expo runs bunx expo export itself, exports into a temporary project-local .otalan/expo-export-* folder, packages the exported JS bundle and assets, and stores the generated Otalan satellite manifest for publish. You do not need to create a dist/ or www/ folder before running it.
otalan publish waits for server-side validation to finish before it returns.
CI/CD Usage
The CLI is designed to work well in CI/CD with a project-scoped OTA Publish Key.
Set these secrets in your CI provider:
OTALAN_API_KEYwith your OTA Publish KeyOTALAN_APP_IDfor an active app
Optional:
OTALAN_API_URL
CI/CD Example: Capacitor
bun install --frozen-lockfile
bun add -g @otalan/cli
bun run build
otalan login --api-key "$OTALAN_API_KEY" --api-url "${OTALAN_API_URL:-https://api.otalan.com}"
otalan init --app-id "$OTALAN_APP_ID"
otalan bundle --target capacitor --platform ios --bundle-from-package
otalan publish --channel productionUse your normal app build command before otalan bundle. The CLI then packages the built web output from dist/ or www/ by default; pass --input-dir <path> if your Capacitor web output uses another folder.
CI/CD Example: Expo
bun install --frozen-lockfile
bun add -g @otalan/cli
otalan login --api-key "$OTALAN_API_KEY" --api-url "${OTALAN_API_URL:-https://api.otalan.com}"
otalan init --app-id "$OTALAN_APP_ID"
otalan bundle --target expo --platform ios --bundle-from-package
otalan publish --channel productionThis runs bunx expo export through the CLI, using a temporary project-local .otalan/expo-export-* folder, packages the exported OTA assets, and publishes the resulting bundle through Otalan's validation pipeline. Do not add a separate web build step just to create dist/ or www/ for Expo.
GitHub Actions Example
name: Publish OTA
on:
workflow_dispatch:
jobs:
publish-ios:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
- run: bun install --frozen-lockfile
- run: bun add -g @otalan/cli
- run: bun run build
- run: otalan login --api-key "$OTALAN_API_KEY"
env:
OTALAN_API_KEY: ${{ secrets.OTALAN_API_KEY }}
- run: otalan init --app-id "$OTALAN_APP_ID"
env:
OTALAN_APP_ID: ${{ secrets.OTALAN_APP_ID }}
- run: otalan bundle --target capacitor --platform ios --bundle-from-package
- run: otalan publish --channel productionAdjust the build step and bundle target for your app:
- Capacitor: keep your web build step and use
--target capacitor - Expo: remove the web build step if not needed and use
--target expo
What It Does
- logs into the Otalan API
- checks API connectivity and OTA Publish Key context
- generates OTA Publish Key and OTA App Key material locally for dashboard import
- links the current repo to an Otalan app
- bundles Capacitor or Expo OTA output
- publishes a bundle with rollout metadata
- lists published bundles
- rolls back to an older bundle
- shows current bundle status
The CLI supports one release write path: otalan publish. There is no separate upload command.
Config Files
Global auth config:
~/.otalan/config.jsonProject config:
otalan.config.jsonExample project config:
{
"organizationSlug": "example-organization",
"projectSlug": "example-project",
"appName": "Example App",
"appId": "com.example.app"
}otalan.config.json only links the repo to an Otalan project/app. Bundle and release targeting data such as target, platform, runtimeVersion, and bundleId live in .otalan/bundle/manifest.json.
Command Reference
otalan help
Shows the available commands and usage notes. Running otalan without arguments prints the same concise command list and notes.
otalan version
Prints the installed CLI version.
otalan version
otalan --version
otalan -votalan keygen
Generates Otalan key material locally without calling the API. Use this for workflows where a team wants to create the key in its own terminal, CI setup, or secrets manager before importing it in the Otalan dashboard.
otalan keygen --kind ci
otalan keygen --kind otaIf --kind is omitted, the CLI prompts for OTA Publish Key or OTA App Key.
The --kind values and generated key prefixes keep the existing internal API identifiers:
--kind cigenerates an OTA Publish Key with theotalan_ci_prefix.--kind otagenerates an OTA App Key with theotalan_ota_prefix.
Output includes both the full Otalan key and the base64url suffix without the otalan_ci_ or otalan_ota_ prefix:
Generated OTA Publish Key.
Full key:
otalan_ci_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Key without prefix:
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxotalan keygen only creates local key material. Importing or activating a key should still happen through an authenticated dashboard flow; an existing OTA Publish Key should not be able to create more keys. OTA App Keys are intended for embedded app update checks and should not be shared or used as CLI credentials.
otalan login
Saves the project OTA Publish Key and API base URL locally.
If auth is already saved, otalan login shows the current API URL as the prompt default and shows the current OTA Publish Key in masked form. Press Enter to keep either value.
otalan login --api-key otalan_ci_xxx --api-url https://api.otalan.comotalan doctor
Checks API connectivity and prints the organization/project context resolved from the configured OTA Publish Key.
otalan doctorCI usage without saved local auth:
otalan doctor --api-key "$OTALAN_API_KEY" --api-url "${OTALAN_API_URL:-https://api.otalan.com}"otalan init
Creates otalan.config.json in the current project.
otalan init lists the active apps in the project resolved from the logged-in OTA Publish Key and lets you select one. appId is scoped to that project, not globally unique across all projects. Archived apps are not listed and are treated as unavailable for CI publish, rollback, status, and bundle listing commands.
Run otalan init once per app repo or working folder. If you switch to another checkout, folder, or app project, run otalan init there too so that folder has its own otalan.config.json.
If you pass --app-id, the CLI validates that the app exists in the logged-in project before writing otalan.config.json. The CLI also stores organizationSlug, projectSlug, and the selected app name as a safety check and display context.
otalan init
# Non-interactive CI usage:
otalan init \
--app-id com.example.appotalan bundle
Builds .otalan/bundle/bundle-<bundle-id>.zip and .otalan/bundle/manifest.json.
.otalan/ is generated output. Add it to your app repo's .gitignore; otalan publish reads the bundle files from the current CI workspace after otalan bundle runs.
Capacitor:
otalan bundle --target capacitor --platform ios
# Custom Capacitor web output folder:
otalan bundle --target capacitor --platform ios --input-dir buildExpo:
otalan bundle --target expo --platform iosCurrent behavior:
- Official support covers Capacitor 7 and 8, and Expo SDK 54 and 55
- Other app targets and older framework versions may work, but they are not officially supported for the moment
- Capacitor packages prebuilt web assets; it does not run your app build command
- without
--input-dir, Capacitor checksdist/first and thenwww/ - pass
--input-dir <path>to package a different Capacitor web output folder - Expo runs
bunx expo export --platform <platform>into a temporary project-local.otalan/expo-export-*folder - Expo does not require a prebuilt
dist/orwww/folder - Expo stores the generated Otalan satellite manifest in
.otalan/bundle/manifest.json, includinglaunchAsset,assets,runtimeVersion,bundleId, andexpoConfig - both outputs produce a ZIP plus
manifest.json - source map files (
*.map) are omitted from bundle ZIPs by default; the CLI prints the omitted file count when any are skipped - native project/source files are rejected before bundle output is written; OTA bundles must only contain generated web/update assets
- when
otalan.config.jsonis available, the CLI prints the linked project and app before packaging - when
otalan loginandotalan initare configured, the CLI checks that the selectedbundleIdis not already published for the selected platform, runtimeVersion, and channel before writing bundle output --platformis required so the CLI exports the selected platform and resolves the correct runtime version
Runtime version defaults:
- In an interactive terminal,
otalan bundleprompts for the runtime version after showing the detected active runtime version. - Capacitor iOS defaults runtimeVersion from
CFBundleShortVersionStringinInfo.plistand resolves$(MARKETING_VERSION)from the Xcode project when needed - Capacitor Android defaults runtimeVersion from
versionNameinandroid/app/build.gradleorbuild.gradle.kts - Expo runtimeVersion reads
--runtime-version, Expo export metadata, or Expo config runtimeVersion policies/strings; if none are present, the CLI falls back to the selected platform Expoversion --runtime-versionoverrides auto-detection
Native project file parsing is best-effort. If your Info.plist, Xcode build settings, or Gradle files use patterns the CLI cannot read, pass --runtime-version explicitly.
For Expo projects, the recommended app config is:
{
"expo": {
"version": "1.0.0",
"runtimeVersion": {
"policy": "appVersion"
}
}
}Use a string value instead if you manage runtime compatibility manually:
{
"expo": {
"runtimeVersion": "1.0.0"
}
}Choose the bundle ID you want to release:
otalan bundle --target capacitor --platform ios --bundle-id 1.0.5
otalan bundle --target expo --platform ios --bundle-id 1.0.5bundleId is the customer-facing OTA release identifier for all targets. The CLI maps it to the target-specific metadata internally.
If you omit bundleId:
- in an interactive terminal, the CLI prompts for a bundle ID and shows the local bundle ID from
.otalan/bundle/manifest.jsonwhen available - when
otalan loginandotalan initare configured, the prompt also shows the active published bundle ID for the selected platform/runtime version/channel - published bundle hints use
--channel, defaulting toproduction - duplicate published bundle ID checks use the same
--channelvalue and default toproduction - pressing Enter without a bundle ID keeps the automatic bundle ID behavior
- the CLI reads
runtimeVersionfrom the selected platform and adds a short hash suffix - example:
1.0.0-abc123def456
If you want to take the bundle ID from package.json instead:
otalan bundle --target capacitor --platform ios --bundle-from-package
otalan bundle --target expo --platform ios --bundle-from-packageotalan publish
Publishes the current bundle output with rollout metadata.
otalan publish uses the bundleId, platform, and runtimeVersion stored in .otalan/bundle/manifest.json. To release 1.0.5, set it when you run otalan bundle --bundle-id 1.0.5.
Current behavior:
channelis chosen at publish time- publishes are mandatory by default
- default rollout is
100 --platformand--runtime-versioncan override the manifest, but only if they match it--output-dirlets you publish a bundle from a non-default folder--rollout-percentaccepts an integer from0to100--optionalmarks the update as non-mandatory--release-notesattaches release notes to the published bundle- Expo publish forwards the full generated Otalan satellite manifest when present
- Expo publish sends the generated manifest with
runtimeVersion - Expo manifests include the Expo config captured from
bunx expo config --json; avoid placing secrets in Expo config fields that are not intended to be uploaded - Otalan validates the release ZIP before the publish completes
- active rollouts can be paused and resumed later without changing the selected bundle
Default flow:
otalan publish --channel productionStaged rollout:
otalan publish --channel production --rollout-percent 25 --release-notes "Fix startup crash"Optional update:
otalan publish --channel production --optionalThis uses the direct-upload release flow:
POST /v1/releases/createwith JSON metadata for the release and local ZIP, includingexpoManifestfor Expo bundlesPUTthe ZIP bytes directly to the returned opaqueuploadUrlwith the exact returneduploadHeaders, includingContent-LengthPOST /v1/releases/ingests/:id/complete- poll
GET /v1/releases/ingests/:iduntil the ingest reachesreadyorfailed
If the direct object-storage upload fails before completion, the CLI calls POST /v1/releases/ingests/:id/cancel so the reserved ingest does not block a retry.
The ZIP is opened as a disk-backed Bun.file and passed directly to the returned PUT upload URL; otalan publish does not load the full archive into memory first.
If validation fails, otalan publish exits non-zero and prints the ingest failure reason when the API provides one. This makes the command safe to use directly in CI/CD pipelines.
otalan bundles
Lists remote bundles for the current app so you can choose a bundle for rollback or rollout operations.
Remote bundle tables display the API publishedAt timestamp, not the bundle row createdAt timestamp.
Default resolution order:
--runtime-version.otalan/bundle/manifest.jsonif present and the manifest platform matches the selected platform- runtime version derived from the selected platform in the local app project
- interactive prompt
otalan bundles --platform ios --channel productionotalan rollback
Reactivates an older bundle for the same tuple.
rollback uses the same runtime-version default order as bundles. Pass --runtime-version if you want to override the detected default.
otalan rollback --bundle-id 1.0.0-web.1 --platform ios --channel productionotalan pause
Pauses delivery of the currently active bundle for the selected release tuple.
pause uses the same runtime-version default order as bundles. The active bundle remains selected, but new OTA checks stop receiving it until you resume the rollout.
otalan pause --platform ios --channel productionotalan resume
Resumes delivery of the currently active bundle for the selected release tuple.
resume uses the same runtime-version default order as bundles.
otalan resume --platform ios --channel productionotalan status
Shows the active bundle for the selected release tuple.
The active bundle summary displays publishedAt as Published at.
status also uses the same runtime-version default order as bundles.
otalan status --platform ios --channel productionBundle Output
Capacitor Manifest
{
"target": "capacitor",
"hash": "sha256...",
"runtimeVersion": "1.0.0",
"bundleId": "1.0.0-abcdef123456",
"createdAt": "2026-04-07T12:00:00.000Z",
"platform": "ios"
}Expo Satellite Manifest
{
"target": "expo",
"hash": "sha256...",
"runtimeVersion": "1.0.0",
"bundleId": "1.0.0-abcdef123456",
"launchAsset": "bundles/ios-xxxxx.js",
"assets": [
"assets/asset_1.png"
],
"expoConfig": {
"name": "Example",
"slug": "example",
"scheme": "example"
},
"createdAt": "2026-04-07T12:00:00.000Z",
"platform": "ios"
}For Expo publishes, otalan publish serializes this file and sends it to /v1/releases/create as expoManifest.
Maintainer Release Checklist
Before publishing a public package release:
- update
package.jsonto the new package version - add the matching
CHANGELOG.mdentry - run the release checks and inspect the package dry run
bun install --frozen-lockfile
bun test
bun run check
bun run lint
bun run build
bun pm pack --dry-runprepublishOnly reruns tests, TypeScript, and ESLint. The scoped npm package is configured for public publishing through publishConfig.access.
Notes
- This is a Bun-based CLI published on npm.
- Expo bundling uses
bunx expo .... - Default API URL is
https://api.otalan.com. - Publishing, rollback, status, and
bundlesexpect an OTA Publish Key and an active app. - Bundle and release commands print the linked project and app before continuing when project config is available.
- Run
bun run buildafter changing CLI source if you wantdist/bin.jsupdated locally.
