Migrate
One command moves a project from commander, yargs, chalk, ora and a dozen more incumbents to the family's drop-ins — every import the compatibility oracle grades level, and nothing it does not.
npx burgee migrate --dry-run
npx burgee migrateburgee migrate rewrites import specifiers, and only specifiers: 'chalk' becomes
'roundel/chalk', 'commander' becomes 'burgee/commander', and the code around them is
left exactly as it was. It then prints what it changed, what it would not change and why,
the family packages to add, and the one command that adds them and removes the incumbents —
for the package manager your lockfile names, each family package pinned to the version that
carries the graded drop-in:
next: npm install burgee@^X flagstaff@^Y roundel@^Z && npm uninstall commander chalk oraX, Y and Z are the versions this burgee was built beside, read from each package's own
manifest at build time; Run it shows a real run. Unpinned, a package manager is free to pick an older release that
predates the drop-in; under pnpm's minimumReleaseAge one did exactly that. When your
project sets a minimum release age (minimumReleaseAge in pnpm-workspace.yaml, or
minimum-release-age in .npmrc), the report says so under releaseAge: a graded version
younger than the rule allows will not install. next then pins the newest release old enough,
but never one older than the first release that exports every subpath the rewrite imports:
roundel/picocolors first shipped in roundel 1.1.0, so a project moved onto it is never pinned
to roundel@^1.0.4. When no release is both old enough and carrying the subpath, the note says
which subpath and the date the first one that does becomes installable.
The first line of the report says how it went — complete: 3 files rewritten,
partial: 2 files rewritten, 1 refused, blocked: 1 preflight check; nothing written (3 files would be rewritten), or nothing to rewrite — and the rest is the detail, as text. --json
gives the same report as data, with that line as summary. The command exits 1 when anything
was refused or blocked, so an agent can run it unattended and branch on the result. A refused run is partial, not undone: the files that could move have moved, and the
summary says how many.
What it moves, and what you choose
By default it moves the incumbents your package.json declares — in dependencies,
devDependencies, optionalDependencies or peerDependencies. An incumbent your code
imports without declaring it, because it arrives through another package (@inquirer/core
through @inquirer/confirm, say), is listed under undeclared and left alone. Two flags
change the choice, both taking incumbent package names, comma-separated or repeated:
npx burgee migrate --only chalk,ora # these and nothing else, declared or not
npx burgee migrate --skip @inquirer/core # everything it would move, except theseA name it does not migrate is a usage error, exit 2, with the list of names it does.
What it rewrites
Every drop-in the compatibility oracle grades level with its incumbent: the incumbent's own test suite passes at least as many cases against the family's replacement as against the incumbent itself, run in the same harness. The grades live on that page and move as it moves; this list is the codemod's own table, and a lock holds the two equal.
| incumbent | becomes |
|---|---|
commander | burgee/commander |
@commander-js/extra-typings | burgee/commander/extra-typings |
yargs | burgee/yargs |
yargs/yargs | burgee/yargs |
yargs/helpers | burgee/yargs/helpers |
yargs-parser | burgee/yargs/parser |
chalk | roundel/chalk |
ora | flagstaff/ora |
log-update | flagstaff/log-update |
boxen | flagstaff/boxen |
cli-table3 | flagstaff/cli-table3 |
string-width | linegauge |
strip-ansi | linegauge/strip |
wrap-ansi | linegauge/wrap |
slice-ansi | linegauge/slice |
cross-spawn | bellpull/cross-spawn |
ansi-escapes | paratext |
which | bellpull/node-which |
rc | seniority/rc |
lilconfig | seniority/lilconfig |
cosmiconfig | seniority |
dotenv | seniority/dotenv |
dotenv/config | seniority/dotenv/config |
dotenv/config.js | seniority/dotenv/config |
find-up | seniority/find-up |
onetime | closeout/onetime |
cli-cursor | closeout/cli-cursor |
cli-truncate | linegauge/cli-truncate |
widest-line | linegauge/widest-line |
colorette | roundel/colorette |
kleur | roundel/kleur |
kleur/colors | roundel/kleur/colors |
picocolors | roundel/picocolors |
supports-color | roundel/supports-color |
yoctocolors | roundel/yoctocolors |
@clack/prompts | caique/clack |
@inquirer/core | caique/inquirer |
@inquirer/prompts | caique/inquirer/prompts |
meow | burgee/meow |
terminal-link | paratext/terminal-link |
term-img | paratext/term-img |
restore-cursor | closeout/restore-cursor |
exit-hook | closeout/exit-hook |
signal-exit | closeout/signal-exit |
signal-exit/signals | closeout/signal-exit/signals |
ink | controlroom/ink |
A test's mocks move with the imports they mock. The first argument of vi.mock,
vi.doMock, vi.unmock, vi.doUnmock, vi.importActual, vi.importMock, jest.mock,
jest.doMock, jest.unmock, jest.requireActual, jest.requireMock,
jest.unstable_mockModule and require.resolve is a specifier like any other, type arguments
included (vi.importActual<typeof import('chalk')>('chalk')). A mock left on chalk while the
code imports roundel/chalk mocks a module nothing loads, and the tests that relied on it fail.
ink brought react-reconciler in as its own dependency; controlroom/ink takes it as an
optional peer, so for an ink program the next command installs it beside controlroom.
The rewrite is for ink 8, the major whose own suite grades controlroom/ink level (1304 of
1304 cases). A project still on ink 6 is listed under offMajor and left alone: ink 6's suite
grades the drop-in 540 of 584, because it follows ink 8 where the two majors disagree.
What it leaves alone, and says so
-
An incumbent it cannot move everywhere. An incumbent moves in every file or in none. When one file is refused, every incumbent that file imports stays imported there, so each of them is listed under
held, with the files that hold it, and rewritten in no file. The same goes for an import kept on the incumbent. Moving the rest would put two copies of the incumbent in one program, and a class from one is notinstanceofthe other's: a prompt cancelled with Ctrl+C throws oneExitPromptError, thecatchtests for the other, and the program prints an error where it used to exit 0. -
An error class another dependency still throws. When a dependency your
package.jsondeclares depends on the incumbent itself —@inquirer/confirmdepends on@inquirer/core— the incumbent stays installed after the migration, and the report lists it undertransitivewith the dependencies and the replacement's error classes. That dependency throws the incumbent's classes, never the replacement's, so an import that names one of them (ExitPromptError,CommanderError,YError…) is kept on the incumbent, and so is one whose names cannot be read (import * as,require()). Kept, it holds the incumbent as above. A type-only import compares nothing at run time, and moves. -
A drop-in that is not level yet. Every graded drop-in is level today. One that passes fewer of its incumbent's cases than the incumbent does is listed under
partialwith the grade, and never rewritten: a codemod that breaks your CLI is worse than none. -
A name the replacement does not export. Every imported name is checked against the replacement's exports. A type-only import of a missing name stays on the incumbent, with a note to keep its types installed; a value import of one refuses the file.
-
An incumbent on a major the oracle did not grade. The grade is for one major:
signal-exit3 exports a function where 4 exportsonExit, andchalk4 is CommonJS where 6 is not. A project whose installed version — or, with nothing installed, whose declared range — is on another major is listed underoffMajorwith both versions, and not rewritten. For commander and yargs the row also says what stands between it and a move: see When your incumbent is on an older major. -
A
require()whose two sides return different kinds of value.require()of an ES module returns its namespace. The incumbents that ship ESM only (chalk 6, ora 10, boxen 9 and ten more) already handrequire()a namespace, and so do their replacements, soconst chalk = require('chalk')moves. Where the incumbent hands back its export itself — a function or a class — and the replacement would hand back a namespace, the file is refused withrequire-of-default. No current replacement is in that position: each one whose incumbent returns a function exports it as'module.exports', which is what Node hands a CommonJS caller.const { Command } = require('commander')moves, because it reads names. -
A clack file that also imports
@clack/core.@clack/promptsreads its settings from@clack/core, andcaique/clackdoes not depend on it. So once the prompts move, anupdateSettingsimported from@clack/corechanges settings nothing reads. A file that imports@clack/promptsand imports,import()s orrequire()s@clack/coreis refused withsibling-state, and the refusal carries itsfix:import { updateSettings } from 'caique/clack' instead of '@clack/core', then re-run burgee migrate. A type-only import of@clack/coreconfigures nothing and does not refuse. A file that imports@clack/coreand not@clack/promptsis not rewritten, so it is not refused either. The check reads one file at a time: anupdateSettingsfrom@clack/corein a file with no clack prompts is not seen. -
A deep import (
commander/lib/command.js) or a specifier that is not a literal (require(name)). The file is refused by name and line, and left exactly as it was — a file is rewritten whole or not at all — and its incumbents are held. A mock or arequire.resolvewhose argument is not a literal is not refused: it loads nothing into the program.Three computed shapes load your own file or your user's module, never an incumbent, so they are not refused, and are listed under
dynamic(read as not an incumbent) instead:- a path from the working directory:
require(path.join(process.cwd(), 'package.json')), orpath.resolve, or from__dirname, followed by string literals only; - a member of a parsed argv object:
import(argv.parser), andargs,options,optsorflags; - an element of an argv array:
argv.use.map(async (plugin) => import(plugin)), andforEach,flatMaporfilter.
A string anywhere in the file that names an incumbent being moved (
'chalk','node_modules/chalk',`${dir}/chalk`) could be what a computed name is built from. Every computed specifier in that file is then refused, and the file holds that incumbent too, even if it does not import it. - a path from the working directory:
-
A dirty git tree, unless you pass
--force, so the rewrite is one reviewable diff. The command exits 1 and writes nothing, and itshintlists the pathsgit statusprinted, so an install that left an untracked cache behind is named. When every one of them is untracked, thefixsays to add them to.gitignoreor commit them.--dry-runwrites nothing either way, so it skips the check, and a directory that is not a git repository has nothing to be dirty.
When your incumbent is on an older major
Most commander and yargs CLIs are not on the major the drop-in claims, and moving one by hand
is how four of them crashed in our field trial: each used an API its own incumbent removed in a
later major. So for commander and yargs, an offMajor row carries three more fields:
sites: every line that uses an API the incumbent removed after your major, up to the highest major the drop-in claims. Each has itsfile,line,api, the release that removed it (removedIn), the line to write instead (replacement), and the incumbent's own changelog entry (source). A binding such asconst program = require('commander')is one site, at the line that makes it, withfirstUse, the first line that uses it as what is gone.nearest: the smallest major the drop-in claims above yours: 14 for commander 13 and older, and 17 for yargs 16 and older.move: each specifier you import, with the entry it moves to atnearest({ "yargs": "burgee/yargs/v17" }), when no site's API was gone by that major and every specifier you import has an entry there. Otherwise it isnull, and the lines in the way are the sites removed at or beforenearest. A deep import, orcommander/esm.mjs, has no entry, so it stops the move too.
Nothing is rewritten: the major you are on was not graded, so this is a work list, not a
rewrite. The first line says so: nothing to rewrite; 1 line uses an API a claimed major removed (see offMajor). The move is checked against these seven APIs and nothing else. It is
graded at nearest, not at your major, so the rest of the incumbent's own changelog between
the two still applies.
| API | Removed in | Use instead |
|---|---|---|
module-as-program | commander 12.0.0 | const { program } = require('commander') |
multi-char-short-flag | commander 13.0.0 | a one-character short flag, or the long flag alone |
esm-mjs | commander 15.0.0 | import { program } from 'commander' |
rebase | yargs 16.0.0 | path.relative() |
reset | yargs 17.0.0 | a fresh yargs() instance |
on-finish-command | yargs 17.0.0 | await the promise the command's handler returns |
singleton | yargs 18.0.0 | require('yargs/yargs')(process.argv.slice(2)) |
The command also reads each file your package.json bin names, so an extensionless
bin/cli is checked, and moved on a claimed major, like any other source file.
What it checks before it writes
Moving to the family changes what your package needs from the Node that runs it and from its test
runner. Before it writes anything, the command reads your package.json (type, engines.node,
scripts.test, a jest key) and any jest.config.* beside it, and lists what the move costs
under preflight, first in the text report under before the move:
| check | kind | when |
|---|---|---|
require-esm | block | the package is CommonJS and engines.node allows a Node on which require() of an ES module throws ERR_REQUIRE_ESM: below 20.19.0, or 21.0.0 to 22.11.x |
floor | warn | engines.node allows a Node below 22.13.0, cannot be read, or is not declared. Adopting raises your package's floor, which is a semver-major for it |
jest-cjs | block | jest runs a CommonJS package, with neither transformIgnorePatterns nor --experimental-vm-modules: jest's own module loader cannot load an ES-module-only drop-in |
untracked | warn | --force let untracked paths through; they are named, so the report records what it ignored |
Each row has the found text it read, a message, and a fix. For require-esm and floor the
fix is npm pkg set "engines.node=>=22.13.0", the floor every family package
declares. For jest-cjs it is the transformIgnorePatterns that lets jest transform the family's
packages, or running jest under node --experimental-vm-modules. The command runs neither: it
rewrites specifiers and nothing else.
A blocking row means the rewritten project would not load on a Node its own engines allows, or
under its own tests. When one applies and the run would rewrite a file, nothing is written, the
summary starts blocked:, and the command exits 1. The file list is headed
would rewrite, not rewritten (blocked):, and there is no next: line (next is "" in
--json): the install and uninstall follow a move, and the rows' fix lines come first. --force does not lift it: --force skips
the git-tree check and nothing else. A run with nothing to rewrite still prints the rows, and
exits as it would have. The rows cover every incumbent you depend on, including one on a major
the oracle did not grade, because what adopting costs does not depend on which major you are on.
Exit codes
| Code | When |
|---|---|
| 0 | nothing was refused — complete: … or nothing to rewrite. Held, kept, undeclared and off-major incumbents are reported and are not failures. |
| 1 | something was refused — partial: N files rewritten, M refused — or a blocking preflight check stopped the write (blocked: …), or the git tree is dirty and nothing was written. The report is printed either way. |
| 2 | --only or --skip named a package it does not migrate; nothing was read or written. |
What it reports, and never rewrites
blessed, neo-blessed and terminal-kit have no drop-in. Their surfaces are too large to
reproduce honestly, and a screen moved onto controlroom is a different shape, not a
substitution. So the command finds their screen, box, list and key sites and lists each under guided, with
the file, the line, the pattern and the section of that incumbent's coming-from guide. It
rewrites none of them, and they do not change the exit code, because nothing was refused.
A site is reported only when its receiver came from one of the three in the same file:
screen.key( is a key binding when screen came from blessed.screen(), and is not
otherwise. A screen passed in from another file is not followed; that file's import of the
package still is.
| pattern | blessed and neo-blessed | terminal-kit | guide section |
|---|---|---|---|
import | require, import, import() | the same | the mapping |
screen | screen(), program() | createTerminal(), ScreenBuffer | Screen |
alternate-screen | alternateBuffer(), normalBuffer() | fullscreen() | Alternate screen |
box | box(), text(), element(), log(), layout() | — | Box |
list | list(), listtable(), listbar() | singleColumnMenu() and the other menus | List |
key | key(), onceKey(), unkey(), removeKey(), a key… event | grabInput(), a key event | Key handling |
render | render() | a screen buffer's draw() | Render loop |
mouse | enableMouse(), a mouse event | the mouse option, a mouse event | Mouse |
text-input | textbox(), textarea() | inputField() | Text editing in a pane |
The guides are Coming from blessed, Coming from neo-blessed and Coming from terminal-kit.
Why an import, not overrides
An npm overrides entry points an incumbent's name at a package's root, and each family
package's root is its own API, not any one incumbent's. The replacements are subpaths, which
an override cannot reach, so the swap is the import — which is also the change your reviewers
can see.
Fly your own burgee
Every CLI built on burgee can have a flag of its own — generated from two colours, with your glyph on it, from the same code that draws ours.
Switching from commander
A commander alternative that is drop-in compatible: change one import, keep your code and tests, and gain --json, --schema, --mcp and shell completions. Graded by commander's own test suite.