hotcodepush, the HotCodePush CLI: the npm package and the binary that set up, release and manage live updates from the terminal and CI.
The repo is public and MIT; the commands in src/index.ts's registry are built, for Capacitor, Cordova, Expo and React Native projects, and the rest of the spec arrives issue by issue.
Stack: TypeScript compiled by tsc into ESM in dist/, zodline and zod for the commands, @hotcodepush/node for the API, @clack/prompts for the prompts, @napi-rs/keyring for the token, our own WebAssembly build of the Rust crate qbsdiff for the patches, ESLint, Prettier, Vitest, Node 22 as the floor, developed on 24.
The plan is the private handbook repo, checked out beside this one: ../handbook/docs/.
Its cli.md is the spec — every command, flag, file, error code and exit code — and repositories.md › The CLI's structure the layout; both are binding, with api.md for the API the commands call.
When code and plan disagree, stop and surface it; never improvise.
src/
index.ts the registry: space-separated command names, each a lazy import
commands/ a folder per noun and a file per verb, channel/create.ts, resource-file/write.ts; the standalone commands flat, login.ts, init.ts, doctor.ts, open.ts
utils/ the runner, command resolution and did-you-mean, the E_ catalog and its one mapping,
the global options, environment detection, config.json, the token store,
the auth client, the API client, hotcodepush.json and its directory, the JSON files a person edits read as `E_INVALID_JSON` when they do not parse, a resource by id or name,
the project's fingerprint through the protocol's recipe,
a bundle by number or id, a release by number or id in its channel with the wait until it is live,
the released line, the prompts and the confirmation, the pages of a list, the boolean flag, the channel fields,
the release conditions from their flags, the audience in one clause, a device by id, a duration as a time bound,
the framework and the build's directory, `frameworks/` with one module per framework behind one interface
and the registry line that makes the CLI package it, beside them React Native's release build, which
Expo's module runs too, the SDK package's install and check and the native projects as named or asked for,
the files of a build hashed, their gzip copies,
the pack writer, the git provenance, the device hosts derived from the API URL,
the upload flow, the private key an upload is given, read into the pair that signs, and the writer of its file,
the delta bases, the main bundle's patches and the bsdiff module behind one function,
the native glue the Capacitor and Cordova modules name, one constant in `frameworks/native-glue.ts`,
the build step both commands share, the resource file,
the progress lines, the browser opener, the JSON, tables and details output,
init's step runner, the outcome rows init and doctor print, the package manager and its visible runs,
the native-project edits: the binary create phase in the Xcode project and the removal of the resource reference an earlier init added, package.json reading,
the lines a React Native project is wired with
config/ consts: the API URL, the client id and header, the config file, the docs and issues URLs,
the keyring entry, package.json, the project file, the SDK packages' names and pinned specs,
the manual step that runs `init`
test/ the command tests' harness, the API faked behind fetch, their fixtures, the release routes
and the Capacitor project a test writes, with the pbxproj of `cap add ios`, its Gradle file and the old resource reference, the fingerprint inputs,
the Cordova project with its `config.xml` and the native glue a platform copy carries, the React Native project with its pbxproj,
the protocol's fixtures read from the installed package; never built
bsdiff-wasm/ the bsdiff module, our build of the Rust crate `qbsdiff`: the crate that wraps it, its `Cargo.lock`,
the pure-Rust stand-in for `cdivsufsort` under `patches/`, `build.sh`, and the built `bsdiff.wasm`,
committed and shipped in the package; `THIRD-PARTY-NOTICES` at the root carries the notices and licences
of what the module is compiled from and ships beside it
dist/ the build output, never committed
Tests live beside the code they test, *.test.ts next to the file.
There is no services/ and no types/: @hotcodepush/node is the API layer and the type source, pinned to one pkg.pr.new build by its commit hash.
The one exception is Better Auth's /v1/auth/* slice, reached through better-auth/client in utils/auth-client.ts, as the handbook's architecture.md places it.
| Command | Does |
|---|---|
bsdiff-wasm/build.sh |
rebuild bsdiff-wasm/bsdiff.wasm in its pinned Rust image; needs Docker |
npm run build |
compile src/ into dist/ |
npm run fmt |
format with Prettier |
npm run lint |
ESLint |
npm test |
Vitest |
npm run typecheck |
tsc --noEmit, tests included |
Run npm run fmt before every commit; lint, typecheck, test and build must pass, as ci.yml checks on every push to main and every pull request, on Ubuntu, macOS and Windows, each on Node 22 and 24.
A test of a POSIX file mode is skipped on Windows, which has none: a file there is protected by the access list of its folder, the user profile's for config.json and the project's own for a private key file.
Run bsdiff-wasm/build.sh after a change in bsdiff-wasm/ and commit the module with it: the image is pinned by digest so the same sources yield the same bytes, and ci.yml's bsdiff-wasm job rebuilds the module and fails when its bytes differ from the committed file.
node dist/index.js --help runs the build locally.
ci.yml's preview job publishes every push to main and every pull request to pkg.pr.new, and consumers pin one build by its short commit hash: npm install --save-dev https://pkg.pr.new/hotcodepush-team/cli/hotcodepush@<sha>.
No releases yet: the version stays 0.0.0, and release-please and npm provenance arrive with the publish decision.
src/commands/<noun>/<verb>.ts, orsrc/commands/<name>.tsfor a standalone one, default-exports zodline'sdefineCommand.- Its options are
defineCommandOptions({ … }), so the global options come with every command. - It has a
descriptionand exactly twoexamples, which end its--help. - One line joins the registry in
src/index.ts:'channel create': () => import('./commands/channel/create.js').
src/utils/frameworks/<framework>.tsexports aFrameworkModule: the build output, read when the upload runs, or the reason there is none, the native projects, the optionalnativeGluePathsleft out of the embedded manifest and of an upload, whatinitinstalls and wires, whatdoctorchecks, and, where the framework has one, the optionalresolveMainBundlePath, which names the main JavaScript bundle among a bundle's files for the delta packs to carry as a patch.- One line joins the registry in
src/utils/frameworks/index.ts. - Nothing outside the module names the framework: a command asks the module, never a config file or a path of its own.
Capacitor's module reads capacitor.config as text and no native file, webDir when the upload runs, and a config with no webDir readable as a quoted string makes --path required, E_MISSING_PARAMETER naming the reason; Cordova's reads config.xml through fast-xml-parser for the file-mode preference alone. The store build's identity comes from the build on every framework, never from a project file: the hook script passes the version and the build number it read from the processed Info.plist or the variant. Capacitor and React Native are wired the same way, init adding the phase and the Gradle line; Cordova's plugin and Expo's config plugin wire themselves.
React Native's and Expo's have no build output to read and share React Native's release build in frameworks/react-native-build.ts: packageReactNativeBundles runs the module's bundler and Hermes' compiler per platform into the command's packaging directory, one bundle each, collectEmbeddedFiles takes main.jsbundle and assets/ out of the app the Xcode phase points at, the native build passes the identity and --resource-file-path in, and resolveMainBundlePath answers main.jsbundle on iOS and index.android.bundle on Android where a bundle's files hold it.
The bundler is all the two differ in there: react-native bundle with the project's entry file for React Native, expo export:embed, which resolves the entry file itself, for Expo.
--path is the prepared bundle directory of the one platform --platform names, refused without the JavaScript under the name that platform's app loads.
React Native's wiring is five edits, each recognised afterwards by what it wrote: the Xcode phase through the xcode package, and one line each in build.gradle, AppDelegate.swift, MainApplication.kt and the Podfile, in utils/react-native-project.ts.
Expo's wiring is one entry, the SDK's config plugin in the plugins of the JSON app config, app.config.json before app.json, and a project without an app config gets an app.json; the plugin makes React Native's five edits at prebuild, so the module names no native file and init runs no prebuild.
A project with an app config that is code, or with a JSON one that does not parse, gets no edit: the entry to add is the manual step.
doctor's hook row finds the entry in either app config: among the plugins of a JSON config it parses, otherwise by the package's name in the text, never by evaluating code.
- A command throws, the entry point exits.
A command never calls
process.exit: it throws an error fromutils/errors.ts, andrunClimaps it to the message and the exit code once. A new code joins that catalog as a class with its code, exit code, message and fix, and gets its section athotcodepush.com/docs/cli/errors#<CODE>. - An error is one line: the code, what happened, what to do, the docs URL.
The message is lowercase without a closing period; the fix is one lowercase sentence carrying its own punctuation; a stack trace only with
--verbose. - API errors pass through verbatim, their code, message and fix as received and never re-mapped; the CLI's catalog covers only what happens before or without the API.
- Exit codes:
0done,1an error,2a missing or invalid parameter,3not logged in,4a confirmation required without--yes. - Parameters are the API's field names,
--rollout-percentage,--channel: nothing is renamed between the console, the API and the terminal. The one exception is a version:--versionis the CLI's own flag, so a bundle'sversionis--bundle-versionand a binary'sversionandbuildare--binary-versionand--binary-build, the names a device'sbinaryVersionandbinaryBuildalready carry. The schema key is the camelCase field, and zodline takes the kebab-case flag. - Options are optional in the schema.
A missing required parameter is a prompt when interactive and a
MissingParameterErrornaming the flag otherwise, never a zod error. - Interactive means a TTY, no
CI, and neither--jsonnor--yes, whichisInteractive(options)decides; nothing prompts without it. - A command confirms once when it changes what devices receive, sends something to a person or cannot be undone, stating the consequence.
--yesskips the question; non-interactive without--yesthrowsConfirmationRequiredError; creates and reads never confirm. --jsonis data only: the human output's content on stdout and nothing else, no prompt, no progress line, no notice. An error under--jsonis{ "error": { "code", "message", "fix" } }with its exit code; without--json, output goes to stdout and diagnostics to stderr.- Colour only where
isColorEnabled(stream)holds: a TTY, noNO_COLOR, noTERM=dumb. zodline colours its help regardless, sorunClistrips the codes from its output where colour is off. - Commands call commands: a command that needs what another does runs that command's action in place, never a copy of its logic.
- A command that reports several outcomes —
init's steps,doctor's checks — prints them itself and ends withReportedFailureErrorwhen one failed, so the exit code is set without a second message; nothing else throws it. initedits only what it can recognise afterwards: the Xcode project gains one run-script phase through thexcodepackage and loses the resource reference an earlierinitadded, a Capacitor or React Native project's other files one line each, an Expo project's JSON app config one plugin entry, andinitnames the files it will change and asks once before touching them.- The token is
readToken():HOTCODEPUSH_TOKENwhen set, then the keyring, then theconfig.jsonfallback that any keyring failure latches for the rest of the process. - An upload never holds a file in memory: every file is hashed and gzip-compressed through streams into a temporary directory,
put as a
Blobopened from disk so the client can retry it, and the Node client splits it into parts above itsSINGLE_UPLOAD_LIMIT_BYTES; the packs go the same way. The one exception is a patch:utils/bsdiff.tsreads the main bundle and its base whole into the module's memory while it computes one, and holds the patch until it is written. Only the hashes the API answers as missing move, then the full pack and one delta pack per base; the bases and their file lists are read from the API before the bundle is created, and an API that cannot be reached there fails the upload, nothing skipped. - An upload is signed when
hotcodepush.jsonlists a public key, with the private key it is given: the file--private-key-pathnames, otherwise the key's text inHOTCODEPUSH_SIGNING_KEY, which a CI sets from a secret; an empty variable is unset. The CLI stores no private key and looks for none;bundle uploadandrelease create, where it uploads, take both, and the key must belong to one of the listed public keys. A key is read through Node's key import as PKCS #8 or as PKCS #1, the form Expo's tool writes, with its PEM lines or without them, on one line too. Keys listed and no key given isE_SIGNING_KEY_UNAVAILABLEbefore a byte moves, since the app would refuse the unsigned bundle. A dry run requires no key, since it signs nothing; a key it is given is still checked. A key given while none is listed, a file that cannot be read, an encrypted key, a key that is no RSA key of at least 2048 bits and a key that belongs to no listed public key areE_INVALID_PARAMETER, the message naming the flag or the variable the key came through. No listed key and no key given means no signature, and the API'sE_SIGNATURE_REQUIREDpasses through. The signed bytes are the manifest as the API rebuilds it — the files by path, the platforms sorted, by code units — sobuildManifestToSignand the API's builder change together.signing-key createwrites the private key as a PEM file of PKCS #8 through Node's key export, to--private-key-pathor tohotcodepush-private-key.pemin the working directory, mode0600on macOS and Linux. It refuses an existing file and a missing folder before any request, writes the file before it registers the public key and removes it again when the registration fails, and appends the public key topublicKeyslast; its--jsoncarriesprivateKeyPath, never the key.doctorlooks for no key file: it checks that the app has registered the listed public keys and, where the variable is set, that its key belongs to one of them.signing-key addreads the private key file--private-key-pathnames through the same import, derives the public key from it, refuses a key that is no RSA key of at least 2048 bits before any request, registers the public key and appends it topublicKeysunless listed; it writes, copies and moves no key file, and its--jsoniscreate's withoutprivateKeyPath.signing-key deleteof the app's last key states, in the confirmation and the output, that binaries built with it refuse unsigned releases until they are replaced. A private key never reaches a message, a progress line or an error. Signing is RSA alone,rsa-v1_5-sha256, the keys of 4096 bits and none under 2048 taken;binary createwrites each listed public key into the resource file in the encoding the platform's own API imports — PKCS #1 DER on iOS, SPKI DER on Android — beside its key id, through Node's key export inutils/resource-file.ts, never by hand. - A delta pack per base, a patch for the main bundle alone: the bases are the three newest earlier complete uploaded bundles with the bundle's fingerprint that share a platform with it,
and the three newest binaries with that fingerprint on each platform the bundle names, by when they were created, the base being the binary's embedded bundle.
A delta pack carries the files its base lacks; none is made against a base that would get every file whole, nor against one that holds them all.
A framework names its main JavaScript bundle through the hook
resolveMainBundlePath(files, platform), asked for the base's files and for the new bundle's, so the two are paired by role, never by path, since a file name may carry its content hash. Only the delta packs against the newest earlier bundle and against the binaries carry it as a patch entry in place of the file, and only when the patch is smaller than the stored object it replaces; the second and third earlier bundle get the file whole. A patch that cannot be made — a base file the files host does not answer or answers with other bytes, a diff that fails — sends the file whole and says so in one line, never failing the upload. A patch is made once per pair of contents, however many bases share it; the base files are fetched by hash from the files host three at a time, and the diffs run one after another. bsdiff goes throughutils/bsdiff.tsalone, over the module inbsdiff-wasm/: the BSDIFF40 format the SDKs apply, the algorithmqbsdiff's and never written here. - The build step is two commands over one module,
utils/build-step.ts:resource-file writeresolves the channel and writes the resource file;binary createdoes the same and creates the binary first, so the file carries its embedded bundle's id. An id is taken as it is and never asks the API; a name is resolved through the API, which alone knows the id the resource file carries, and a name the app lacks fails everywhere withE_INVALID_PARAMETER. On Capacitor and Cordova the embedded manifest leaves outNATIVE_GLUE_PATHS, andbundle upload,release create's upload and its dry run leave out the same glue, with one progress line.resource-file writenever fails for want of a token or an API, in CI too: withHOTCODEPUSH_OFFLINE=1, read inutils/environment.ts, or without a token, the API is asked nothing, the file carrieschannelId: nullor the id it was given, one warning is printed and the exit is 0; an API that cannot be reached or refuses while a name is resolved ends the same way.binary createis the same on a laptop, warning and creating nothing, and loud in CI: without a token the build fails withE_NOT_LOGGED_IN, exit 3, its fix namingHOTCODEPUSH_TOKEN, and a failed resolution or a failed creation fails the build there,E_BINARY_CONFLICTunder an unbumped build number being a pipeline mistake.isCi()decides that alone; whether a binary is created is the hook script's choice, by the build's own variables, never the CLI's. A build that bundled nothing — a React Native or Expo debug build, Metro serving its JavaScript — is neither: before any token is read, the file is written withembeddedBundle: nulland the channel id only where the project named the channel by id, no binary is created, one line on stderr says so and the exit is 0; every check in that build answersSKIPPEDwithBUILD_DEBUG. The fingerprint is the strict half too: a project the recipe cannot read isE_FINGERPRINT_UNAVAILABLEonbinary createand upload alike, nevernull, since a release targets it. The device hosts it writes derive from the API URL: none for production, the staging hosts for staging,<apiUrl>/filesand/updatesfor any other,HOTCODEPUSH_FILES_BASE_URLandHOTCODEPUSH_UPDATES_BASE_URLoverriding. - Every login keeps its device code, expiry, user code and verification URL in
config.jsonuntil approved; the nextloginredeems the code first and, while it is pending, shows the same code again, so an agent sees one output until the person approves.
- A name says what the function does on first read: the verb, the object and, where it matters, the qualifier.
- Prefixes from the monorepo's vocabulary:
fetchHTTP,resolvederivations without I/O,buildfunctions that assemble a document without sending it;read,writeanddeletefor the files and the keyring. - Result variables carry the past participle of their operation,
loadedCommands. - Alphabetical ordering within a scope.
The object passed to
defineCommandis the one exception, read in the orderdescription,examples,args,options,action: what the command is, how it is called, what it does. - One thing per function, its name saying which; never a function that both decides something and phrases the message about it.
- Booleans carry
isorhas; a state with a moment is a timestamp such aspausedAt, never a boolean. - Error codes are
E_plus SCREAMING_SNAKE. - Never the non-null assertion; nullish coalescing or a real check.
- Test titles read
should <verb> …, lowercase, conditions starting withwhen. - Fixtures and examples carry invented data only.
.mcp.jsonis absent on purpose: the one server for this repo's stack, HotCodePush's own athttps://mcp.hotcodepush.com/mcp, is not live yet; the file arrives with it..claude/skills/holds the developer skills copied fromhotcodepush-team/.githubwith the skills CLI and pinned inskills-lock.json; update them withnpx skills update..github/copilot-instructions.mdholds the review criteria, the only Copilot-specific file.- Commits are conventional commits;
mainis trunk, CI is the gate, and a commit that lands an issue saysCloses #<n>.