burgee

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 migrate

burgee 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 ora

X, 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 these

A 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.

incumbentbecomes
commanderburgee/commander
@commander-js/extra-typingsburgee/commander/extra-typings
yargsburgee/yargs
yargs/yargsburgee/yargs
yargs/helpersburgee/yargs/helpers
yargs-parserburgee/yargs/parser
chalkroundel/chalk
oraflagstaff/ora
log-updateflagstaff/log-update
boxenflagstaff/boxen
cli-table3flagstaff/cli-table3
string-widthlinegauge
strip-ansilinegauge/strip
wrap-ansilinegauge/wrap
slice-ansilinegauge/slice
cross-spawnbellpull/cross-spawn
ansi-escapesparatext
whichbellpull/node-which
rcseniority/rc
lilconfigseniority/lilconfig
cosmiconfigseniority
dotenvseniority/dotenv
dotenv/configseniority/dotenv/config
dotenv/config.jsseniority/dotenv/config
find-upseniority/find-up
onetimecloseout/onetime
cli-cursorcloseout/cli-cursor
cli-truncatelinegauge/cli-truncate
widest-linelinegauge/widest-line
coloretteroundel/colorette
kleurroundel/kleur
kleur/colorsroundel/kleur/colors
picocolorsroundel/picocolors
supports-colorroundel/supports-color
yoctocolorsroundel/yoctocolors
@clack/promptscaique/clack
@inquirer/corecaique/inquirer
@inquirer/promptscaique/inquirer/prompts
meowburgee/meow
terminal-linkparatext/terminal-link
term-imgparatext/term-img
restore-cursorcloseout/restore-cursor
exit-hookcloseout/exit-hook
signal-exitcloseout/signal-exit
signal-exit/signalscloseout/signal-exit/signals
inkcontrolroom/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 not instanceof the other's: a prompt cancelled with Ctrl+C throws one ExitPromptError, the catch tests 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.json declares depends on the incumbent itself — @inquirer/confirm depends on @inquirer/core — the incumbent stays installed after the migration, and the report lists it under transitive with 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 partial with 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-exit 3 exports a function where 4 exports onExit, and chalk 4 is CommonJS where 6 is not. A project whose installed version — or, with nothing installed, whose declared range — is on another major is listed under offMajor with 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 hand require() a namespace, and so do their replacements, so const 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 with require-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/prompts reads its settings from @clack/core, and caique/clack does not depend on it. So once the prompts move, an updateSettings imported from @clack/core changes settings nothing reads. A file that imports @clack/prompts and imports, import()s or require()s @clack/core is refused with sibling-state, and the refusal carries its fix: import { updateSettings } from 'caique/clack' instead of '@clack/core', then re-run burgee migrate. A type-only import of @clack/core configures nothing and does not refuse. A file that imports @clack/core and not @clack/prompts is not rewritten, so it is not refused either. The check reads one file at a time: an updateSettings from @clack/core in 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 a require.resolve whose 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')), or path.resolve, or from __dirname, followed by string literals only;
    • a member of a parsed argv object: import(argv.parser), and args, options, opts or flags;
    • an element of an argv array: argv.use.map(async (plugin) => import(plugin)), and forEach, flatMap or filter.

    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 dirty git tree, unless you pass --force, so the rewrite is one reviewable diff. The command exits 1 and writes nothing, and its hint lists the paths git status printed, so an install that left an untracked cache behind is named. When every one of them is untracked, the fix says to add them to .gitignore or commit them. --dry-run writes 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 its file, 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 as const program = require('commander') is one site, at the line that makes it, with firstUse, 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 at nearest ({ "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 is null, and the lines in the way are the sites removed at or before nearest. A deep import, or commander/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.

APIRemoved inUse instead
module-as-programcommander 12.0.0const { program } = require('commander')
multi-char-short-flagcommander 13.0.0a one-character short flag, or the long flag alone
esm-mjscommander 15.0.0import { program } from 'commander'
rebaseyargs 16.0.0path.relative()
resetyargs 17.0.0a fresh yargs() instance
on-finish-commandyargs 17.0.0await the promise the command's handler returns
singletonyargs 18.0.0require('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:

checkkindwhen
require-esmblockthe 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
floorwarnengines.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-cjsblockjest runs a CommonJS package, with neither transformIgnorePatterns nor --experimental-vm-modules: jest's own module loader cannot load an ES-module-only drop-in
untrackedwarn--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

CodeWhen
0nothing was refused — complete: … or nothing to rewrite. Held, kept, undeclared and off-major incumbents are reported and are not failures.
1something 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.

patternblessed and neo-blessedterminal-kitguide section
importrequire, import, import()the samethe mapping
screenscreen(), program()createTerminal(), ScreenBufferScreen
alternate-screenalternateBuffer(), normalBuffer()fullscreen()Alternate screen
boxbox(), text(), element(), log(), layout()—Box
listlist(), listtable(), listbar()singleColumnMenu() and the other menusList
keykey(), onceKey(), unkey(), removeKey(), a key… eventgrabInput(), a key eventKey handling
renderrender()a screen buffer's draw()Render loop
mouseenableMouse(), a mouse eventthe mouse option, a mouse eventMouse
text-inputtextbox(), 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.

On this page