@arthur2079/rangutopia
v0.19.0
Published
A CLI to manage our scripts across the organization.
Readme
rangutopia
A CLI to manage our scripts across the organization.
library build
Builds one library package. Run it from inside the package — the nearest package.json walking up from the current working directory is the package that gets built.
By default it runs two tools in parallel:
- tsc emits the type declarations (
--declaration --emitDeclarationOnly --project tsconfig.build.json). - esbuild emits the bundled, minified ESM JavaScript.
Passing --tsc-only drops the esbuild half — see tsc-only builds below.
Usage
rangutopia library build
rangutopia library build --inputs src/mod.ts
rangutopia library build --inputs src/index.ts,src/helpers/index.ts
rangutopia library build --splitting
rangutopia library build --external react,react-dom
rangutopia library build --external-all-except @walletconnect/modal
rangutopia library build --tsc-onlyPer-package requirements
- A
tsconfig.build.jsonin the package root. ItsoutDirdecides where tsc writes. - An entry point. Without
--inputsthe first ofsrc/index.ts,src/mod.tsthat exists is used. typescriptandesbuildas dependencies somewhere up the tree —rangutopia library checkverifies this and the build runs it first. A tsc-only build only needstypescript.
Flags
--inputs <paths>— comma-separated entry points, relative to the package root (e.g.src/main.ts,src/net.ts). Defaults to the first existing path ofsrc/index.ts,src/mod.ts.--external <names>— comma-separated packages to leave unbundled. See https://esbuild.github.io/api/#external.--external-all-except <names>— invert the above: every entry independenciesis external except the listed ones. Use it when you want a mostly-external bundle that still inlines a few packages. Mutually exclusive with--external.--splitting— enable esbuild code splitting.--tsc-only— skip esbuild and build with tsc alone.
With neither --external nor --external-all-except, esbuild runs with packages: "external", so nothing from node_modules is bundled.
Output
Everything lands in the package's dist/, and nothing else: the build writes the compiler output only. Notably it does not persist esbuild's metafile — if you want one for bundle-size tooling, generate it separately rather than expecting a .build.json in dist/.
tsc-only builds
--tsc-only skips the esbuild step and lets tsc produce the JavaScript as well as the declarations. Use it for packages that should ship as plain per-file ESM rather than as a bundle — for example packages whose consumers need to resolve individual modules, or packages where bundling breaks the runtime.
What changes:
| | default (tsc + esbuild) | --tsc-only |
| --------------------- | ------------------------------------- | ---------------------------------------------------- |
| JavaScript emitted by | esbuild | tsc |
| Shape of the output | one bundle per entry point, minified | one .js per source file, unminified |
| Declarations | tsc | tsc |
| Entry points | --inputs or the default entry point | not used — tsc compiles what the tsconfig includes |
| Requires esbuild | yes | no |
Because tsc becomes the only emitter, the build passes --emitDeclarationOnly false explicitly. That matters: it overrides a tsconfig.build.json (or a base config it extends) that turns emitDeclarationOnly on. Without the override you'd get declarations and no JavaScript.
--inputs, --external, --external-all-except and --splitting only configure esbuild, so they do nothing here. Passing any of them together with --tsc-only logs a warning naming them; the build still succeeds.
Enabling it per package
The flag is set per package, in that package's own build script — packages that don't pass it keep the default tsc + esbuild pipeline. Nothing is configured centrally, so opting one package in never touches another.
{
"name": "@scope/some-library",
"scripts": {
"build": "rangutopia library build --tsc-only"
}
}while its siblings stay on the default:
{
"name": "@scope/another-library",
"scripts": {
"build": "rangutopia library build --inputs src/mod.ts"
}
}The release flow
Releasing is split into small commands, so a CI pipeline can run them as separate steps (or even separate jobs) and do its own work in between:
# 1. what should the next versions be?
rangutopia library version --prod --deferred
# 2. can we publish and release them?
rangutopia library version check
# 3. version the repository root and the client apps, optional
rangutopia client version --prod --root --clients @rango-dev/widget-app
# 4. changelog of the repository, optional
rangutopia changelog generate --root --mention @rango-dev/widget --save
# 5. npm, changelogs, github releases, commit and tag
rangutopia library publish --prodEvery step but 3 detects the same set of packages: the ones with new commits since the last release, plus the ones affected through the dependency graph. Nothing is committed before step 5, so the steps see the same git history and agree on the list. Step 3 detects nothing, it versions exactly what it's told — see client version.
Steps 1, 2 and 5 are not optional: versions are always determined with --deferred first, then checked, and only then published. Doing all of it in one command is what --immediate will be, and it isn't implemented yet.
Steps 3 and 4 are for a monorepo released as a whole (client apps and the packages they embed): the root and the clients get a version too, and the repository gets a changelog. 3 runs before 4, so the root changelog carries the new root version, and what they write is yours to commit before 5 — see Nothing is committed.
library version apply is not part of this flow. library publish applies the versions itself, one package at a time, right before publishing it, so it needs them still saved: a package it has to publish with no determined version left is a MissingDeferredVersionError. A full apply before publish is what causes that, since it forgets every package it writes. apply is for writing versions outside a release, and apply --package <name> for a package this release won't publish.
Limitations
The split flow trusts you to run it as one sequence, on one machine, without touching the repository in between. The sharp edges to know about:
- One release at a time per machine. The state file (
rangutopia/library-version.jsonin the OS temp directory) is one file per machine, not per repository, and it doesn't record which checkout it was computed in. Releasing two repositories — or two checkouts of the same repository — at the same time makes the steps read each other's versions. Don't interleave two releases. - A failed
version --deferredmust be re-run. The state file is written as the versions are determined, so a failure can leave a partial state behind, andcheck/applycan't tell a partial state from a complete one — a package the failed run didn't reach would silently keep its old version. After a failure inversion, runversion --deferredagain instead of continuing. library publishconsumes the state file. It reads the determined versions from it, forgets each package as it applies it, and deletes the file when the command is done — including when it bailed out half way, since what is left can't be re-applied on its own. After a failed publish, runversion --deferredagain before trying once more.- A root changelog mentions a version out of the state file.
changelog generate --root --mention <package>takes that package's version from what step 1 determined, since step 5 is what writes it on thepackage.jsonfiles. Run it on the same machine, between steps 1 and 5, or the mention falls back on thepackage.jsonversion — the one of the previous release. - The channel is not checked across steps.
versionsaves its channel, andlibrary publishrestores it when it applies each version, butlibrary publishalso takes its own--prod/--next/--experimentalflag — which is what detects the packages — and nothing verifies the two agree. Pass the same flag to both commands — a CI pipeline should feed them from a single variable, likepublish.ymldoes withPUBLISH_FLAG. - Nothing may be committed between the steps. Every command detects the changed packages from the git history on its own, and nothing checks that
versionandpublishagreed on the same list. A commit in between can make them diverge. The exception is a commit touching nothing but the root and the private packages — what committing steps 3 and 4 looks like — the libraries to publish stay the same, and the private packages it touches join the release commit anyway.
Channels
--prod, --next and --experimental are required by library version, library publish and client version (exactly one of them). They decide how the next version is built and what a release does:
| Flag | Next version | npm dist-tag | changelog & github release | commit | tag |
| ---------------- | ------------------------- | -------------- | -------------------------- | ------ | --- |
| --prod | conventional commits bump | latest | yes | yes | yes |
| --next | x.y.z-next.N prerelease | next | no | yes | no |
| --experimental | 0.0.0-experimental-… | experimental | no | no | no |
The commit and tag columns are what library publish does. client version builds its versions the same way, but never commits or tags.
--since-start makes the detection consider every package of the workspace instead of only the changed ones. Use it for a first release.
library version
Determines the next version of every changed public package, from its conventional commits.
It takes a mode, exactly one of --deferred or --immediate — there is no default. --immediate (determine, check and write in one command) is not implemented yet, so --deferred is the way to version packages today.
Usage
rangutopia library version --prod --deferred
rangutopia library version --next --since-start --deferred
rangutopia library version check
rangutopia library version applyDeferred versions
--deferred stops before touching any file: the versions are saved on a state file, and check / apply pick it up later. Only the conventional commits are read here, nothing is asked to npm or github yet. apply is what writes the versions on the package.json files — in a monorepo, every package that depends on a bumped package gets its dependency range updated (^<new version>) as well.
$ rangutopia library version --prod --deferred
Versions will be saved on /tmp/rangutopia/library-version.jsonThe state file remembers the channel, so check and apply don't need the channel flags again.
library version check— looks each determined version up on npm, in the git tags and in the github releases, saves what it finds next to the version, and fails when any of them is already taken. The npm lookup only compares against the channel's dist-tag (latest/next): a version that was published before but isn't the dist-tag anymore passes the check and fails later, atnpm publish. The lookups run on every channel, but only--prodrefuses to proceed on a conflict — on--nextand--experimentalthe check passes. This is the guardlibrary publishused to run itself, and the only step that needs network access (GH_TOKENfor the github lookup).library version apply— writes the saved versions on thepackage.jsonfiles. A package is forgotten as soon as its version is written, so the state only ever holds what is still pending, and the file is deleted once nothing is left.--package <name>narrows it to one package and keeps the rest saved for later — apply a package only once everything it depends on has been applied, otherwise it's written against a stale dependency range. It's whatlibrary publishruns (silently) for each package it publishes, which is why a fullapplydoesn't belong in a release flow.
The state file is always rangutopia/library-version.json in the OS temp directory, so check and apply find it without being told where it is. It's one file per machine — see Limitations for what that implies.
On GitHub Actions
library version sets a count step output: how many packages it is versioning, 0 when nothing has changed. A workflow can use it to skip the steps that only make sense when something is released — a repository-level version bump, for instance — while check, apply and library publish are safe to run either way (they do nothing on an empty state).
- name: Determine versions
id: version
run: yarn run rangutopia library version --deferred --prod
- name: Bump the repository version
if: steps.version.outputs.count != '0'
run: ...Comparing against '0' rather than testing for a non-empty value keeps the gate fail-open: with an older rangutopia that doesn't set the output, the step still runs.
Flags
--prod/--next/--experimental— the release channel, exactly one is required.--since-start— consider every package of the workspace, not only the changed ones.--deferred— save the versions forcheckandapply, without writing them onpackage.jsonfiles.--immediate— determine, check and write the versions in one command. Not implemented yet, it throws.
check takes no flag at all, everything it needs is in the saved state. apply takes one:
--package <name>— only write the version of this package, and keep the saved versions of the others for a later run. Without it, every saved version is written and the state file is deleted.
library publish
Publishes the public packages on npm and releases them. The versions come from what library version --deferred has saved: a package it has to publish without a determined version is an error (MissingDeferredVersionError), never something to publish as it is. So library version has to have run for the same set of packages, and library version apply must not have consumed the versions in between.
Every package is built first, in one go: the whole list is handed to the build tool, which runs the builds in parallel while respecting the dependency graph. Nothing is published if the build fails.
Then, for each package in dependency order: writes its version on the package.json files (a silent library version apply --package, so a dependent is published with the new version of what it depends on), makes its CHANGELOG.md (--prod only), publishes it on npm, and stages the changed files. Then, for everything that reached npm: a chore(release): publish commit, one annotated tag per package (package-name@version, without the npm scope), a push, and a github release per tag whose notes are the generated changelog.
The first package that can't be published stops the loop. Its version goes back to the one it was on, along with the dependency ranges pointing at it, so nothing is left carrying a version that was never released — the packages published before it keep theirs and are committed, tagged and released as usual. Its CHANGELOG.md entry is written before the npm publish is attempted, so that one is left on the working tree, unstaged.
The release commit also picks up yarn.lock, the private packages whose dependency ranges have been bumped, and the root CHANGELOG.md when step 5 of the flow made one. It never picks up the root package.json: when client version has bumped it, commit it yourself before publishing.
Usage
rangutopia library publish --prod
rangutopia library publish --next
rangutopia library publish --prod --since-startRequirements
gh— the github CLI, authenticated (GH_TOKEN), for the release step. Checking existing releases islibrary version check's job, not this command's.- npm authentication for the publish step. It runs
npm publish(notyarn npm publish) so npm's trusted publishing (OIDC) works.
Private packages are never published. In a monorepo their package.json is still part of the release commit, since their dependency ranges have been bumped.
changelog generate
Generates a changelog out of conventional commits, from the last release tag to HEAD.
Usage
rangutopia changelog generate --root
rangutopia changelog generate --root --save
rangutopia changelog generate --root --mention @rango-dev/widget --save
rangutopia changelog generate --client --save--root generates the changelog of the repository root: the root package.json version, and every commit since the last name@version tag, whatever package that tag belongs to. It's what a monorepo released as a whole (a client app and the packages it embeds) needs, and it's independent from the per-package changelogs library publish writes.
--mention <package> mentions the current version of a workspace package right under the version header:
# 1.4.0 (2026-08-15)
_includes `@rango-dev/[email protected]`_
### Features
…The version is the one this release bumps that package to: library version --deferred saves it, and this command reads it back, so the mention carries what is about to be published rather than what is out already. A package the release doesn't change is mentioned on the version of its package.json, and so is one whose new version has been written there already (library version apply and library publish both forget a package as soon as they write it). See Limitations. It needs a monorepo, and the package has to be part of the workspace — an unknown name is an error, not a silently skipped line.
The version on the header is the root package.json's, so when the root is versioned by rangutopia client version --root, that runs before this command too.
Committing it is up to you, on a release flow as much as outside of one: the release commit of library publish doesn't stage the root CHANGELOG.md, the same way it leaves the root package.json alone — see Nothing is committed.
Without --save the changelog is printed on stdout instead of being written, which makes it easy to pipe somewhere else (release notes, a PR body, …). When there is no commit since the last tag, nothing is generated.
Flags
--root— generate the changelog of the repository root.--mention <package>— mention this workspace package's version under the root version. Only usable with--root.--client— the single-repo client mode. Requires a private rootpackage.json.--library— not implemented yet.--save— writeCHANGELOG.md(prepending to the existing one) instead of printing it.
client version
Determines the next version of the client packages you name and/or of the repository root, and writes them on their package.json files: library version and library version apply in one go, for what library version leaves out. A client is a private package of the workspace, the public ones belong to library version. Monorepo only, for now.
Usage
rangutopia client version --prod --root --clients @rango-dev/widget-app,@rango-dev/widget-playground
rangutopia client version --prod --root
rangutopia client version --next --clients @rango-dev/widget-appExactly one of --prod / --next / --experimental is required, and at least one of --clients / --root.
What it does
The root comes first (with --root), then the clients in the order they are given. Every version is determined before anything is written, so a bad name leaves every file untouched:
$ rangutopia client version --prod --root --clients @rango-dev/widget-app
Current state:
┌─────────┬─────────────────────────┬──────────────┬──────────┬─────────┐
│ (index) │ name │ location │ version │ private │
├─────────┼─────────────────────────┼──────────────┼──────────┼─────────┤
│ 0 │ 'rango-client' │ '.' │ '1.3.0' │ true │
│ 1 │ '@rango-dev/widget-app' │ 'widget/app' │ '0.42.0' │ true │
└─────────┴─────────────────────────┴──────────────┴──────────┴─────────┘
Next state (prod):
┌─────────┬─────────────────────────┬──────────────┬──────────┬─────────┐
│ (index) │ name │ location │ version │ private │
├─────────┼─────────────────────────┼──────────────┼──────────┼─────────┤
│ 0 │ 'rango-client' │ '.' │ '1.4.0' │ true │
│ 1 │ '@rango-dev/widget-app' │ 'widget/app' │ '0.43.0' │ true │
└─────────┴─────────────────────────┴──────────────┴──────────┴─────────┘The next version follows the channel, with the same rules as library version:
--prod— a conventional commits bump. The commits since the last release tag of any package (the lastname@versiontag, whatever package it belongs to) decide the release type, the same rangechangelog generate --rootcovers. Neither this command norlibrary publishtags the root or the clients, so their own<name>@tags could never bound the range — an untagged repository is bumped from its whole history.--next— thex.y.z-next.Nprerelease, counted up on every run.--experimental—0.0.0-experimental-<commit>-<date>, a throwaway version: it's a downgrade, and a--prodbump from it lands on0.0.0. Never commit it —library publishdoesn't either on this channel.
Then, exactly like library version apply: the version is written with yarn (yarn workspace <client> version, or yarn version at the root), and every workspace package that depends on a bumped client gets its dependency range updated (^<new version>). Nothing is detected: unlike library version, this command versions what it's told, every time it runs, and it never looks anything up on npm or github.
Nothing is committed
The changed package.json files (the root, the clients, their dependents) are left in the working tree, staged by nobody. Commit them yourself before library publish — its release commit only stages what it publishes, the affected private packages and yarn.lock, never the root package.json or the root CHANGELOG.md. A commit that touches nothing but the root and private packages doesn't disturb the release, see Limitations.
A failed run may have written some of the files (the root, then client by client), git checkout -- <file> puts them back; and nothing remembers a bump happened, so re-running the command bumps again.
Replacing a hand-written "bump the root" script looks like this, on --prod only:
rangutopia client version --prod --root --clients @rango-dev/widget-app
rangutopia changelog generate --root --mention @rango-dev/widget --save
git add package.json widget/app/package.json CHANGELOG.md
git commit -m "chore(release): bump the repo and client versions" -m "[skip ci]"
rangutopia library publish --prodFlags
--prod/--next/--experimental— the release channel, exactly one is required.--clients <names>— comma-separated names of the client packages to version, e.g.@rango-dev/widget-app,@rango-dev/widget-playground. Every name has to be a private package of the workspace: an unknown name is an error, a public one too (it points atlibrary version), and so is the root's own name (that's--root). Duplicates are versioned once, and an empty list is an error rather than a no-op.--root— version the rootpackage.jsonas well.
client deploy
Deploys private workspace packages via a provider plugin. Only the vercel provider is implemented today (--provider <name>, default vercel); passing any other name throws "not implemented" — the dispatch is in place so adding another provider is one new branch, not a rewrite. Works on nx and turborepo monorepos.
Usage
rangutopia client deploy --prod
rangutopia client deploy --next
rangutopia client deploy --preview
rangutopia client deploy --next --exclude @scope/a,@scope/b
rangutopia client deploy --next --vercel-config ./my-vercel.json
rangutopia client deploy --next --dry-run
rangutopia client deploy --next --provider vercelExactly one of --prod / --next / --preview is required:
| Flag | Vercel target | CLI args used |
| ----------- | ------------------------- | ----------------------------------------------- |
| --prod | production | vercel deploy --prebuilt --prod --skip-domain |
| --next | custom next environment | vercel deploy --prebuilt --target next |
| --preview | built-in preview | vercel deploy --prebuilt --target preview |
Required environment variables
| Variable | Purpose | Required for |
| -------------------------- | ---------------------- | ---------------------------------------------------------- |
| VERCEL_TOKEN | Vercel CLI auth token | non-dry-run only |
| VERCEL_ORG_ID | Vercel organization ID | non-dry-run only |
| VERCEL_PROJECT_ID_<NAME> | Per-package project ID | always — see "Project-ID requirement by environment" below |
--dry-run skips vercel pull and vercel deploy, so it never reads VERCEL_TOKEN / VERCEL_ORG_ID.
Project-ID convention
For each deployable package, the env var name is derived from the package name:
- Drop the npm scope (
@scope/). - Uppercase the result.
- Replace any character that is not
[A-Z0-9]with_. - Prefix with
VERCEL_PROJECT_ID_.
Examples:
| Package | Env var |
| ------------------------------- | -------------------------------------- |
| @rango-dev/widget-app | VERCEL_PROJECT_ID_WIDGET_APP |
| @rango-dev/queue-manager-demo | VERCEL_PROJECT_ID_QUEUE_MANAGER_DEMO |
| rango-dapp | VERCEL_PROJECT_ID_RANGO_DAPP |
Per-package requirements
A package opts in by being "private": true in its package.json. The "main" field must point to a file inside the build-output directory — its directory will be uploaded to Vercel. Example:
{
"name": "@scope/widget-app",
"private": true,
"main": "dist/index.html"
}If main is missing, the deploy command throws MissingMainError naming the package.
Project-ID requirement by environment
--previewis strict. Every selected package must have a configuredVERCEL_PROJECT_ID_<NAME>. A missing ID throwsMissingVercelProjectIdErrorwith the expected env var name. Rationale: preview is the deliberate per-package test path, and silent skipping would hide misconfiguration.--prodand--nextare lenient. Packages without a project ID are silently skipped — useful for workspaces where only some private packages are deployable apps.
vercel.json
Per invocation, the deploy resolves a vercel.json for each package in this order:
--vercel-config <path>if passed — wins for every package, no per-package override.<pkg.location>/vercel.jsonif it exists — the package ships its own.templates/deploy/vercel.json— the shipped default,{ "rewrites": [{ "source": "/(.*)", "destination": "/" }] }.
Supported keys at the top level of any vercel.json: routes, rewrites, redirects, headers, cleanUrls, trailingSlash — the input shape accepted by @vercel/routing-utils.getTransformedRoutes. Anything else throws.
A fingerprint cache-control rule (s-maxage=31536000, immutable for *.<hash>.{css,js,png,jpg,webp,avif,svg}) is prepended to the route list before being written to .vercel/output/config.json.
Flags
--provider <name>— deploy provider. Defaults tovercel. Any other value throws "not implemented".--exclude <names>— comma-separated package names to skip (e.g.--exclude @scope/a,@scope/b).--vercel-config <path>— explicitvercel.jsonused for every package, overriding per-package files and the shipped template. Resolved relative to the current working directory.--dry-run— runs the filter and produces the build-output artifact at<pkg.location>/.vercel/output/{static,config.json}, but skipsvercel pullandvercel deploy. Doesn't needVERCEL_TOKEN/VERCEL_ORG_ID.
Example GitHub Actions step
- name: Deploy
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID_WIDGET_APP: ${{ secrets.VERCEL_PROJECT_ID_WIDGET_APP }}
run: |
yarn global add vercel
rangutopia client deploy --preview