Skip to main content
A CLI command is a class. It extends Command({ name, description }), declares its arguments and options as decorated fields, takes its collaborators through its constructor, and runs in handle(io). The framework does the parsing, the help, the prompts, the output and the exit codes. Its design is ADR 0506. The package is @stackra/console, and its scaffolder is the stackra-console bin.

Write a command

Put the command in commands/ (a package’s src/node/commands/, an app’s src/commands/, a module’s commands/) and name the file <name>.command.ts. The class is the file’s stem in PascalCase with Command appended.
The rules that follow from the declarations:
  • Name. name and each alias are lower-case segments joined by -, with : between segments (tenant:seed). A bad name, a required argument after an optional one, two fields with one flag, or an option that reuses a global option is refused with ConsoleDeclarationError, before any command runs.
  • Arguments bind in field order to the positional tokens. A field with no initialiser is required, unless it is @Argument({ optional: true }). The runtime cannot see a ?, so write optional: true for an optional argument with no default. A @List argument comes last and takes the rest.
  • Options bind to --<kebab-name>, and to -<short> when short is given (one letter). Every option is optional. A -n, -h, -V, -v or -q is a global option and cannot be used.
  • Value kind comes from @Str, @Int, @Num or @List on the field (@stackra/validation). @Int and @Num take min and max, and an out-of-range value is a usage error. A field with none of them is a boolean option or a string argument.
  • Defaults are the field initialisers. A boolean option starts at its initialiser, false in the example.
  • The constructor only stores collaborators. The kernel builds a throwaway instance with no arguments to read the defaults. If the constructor throws, the defaults are lost silently.
  • handle(io) returns an exit code from 0 to 255, nothing (exit 0), or an object or array, which becomes the --json data. Any other return value, or a throw, exits 1.
The package compiles field decorators in the legacy form ((target, property)), so the tsconfig needs experimentalDecorators: true, as the scaffolder writes it.

Register the commands: createConsole and nodePorts

A standalone program passes its commands to createConsole and runs them with the process’s ports:
  • createConsole checks the registry when it is called: a duplicate name or alias throws DuplicateCommandError, and a bad declaration throws ConsoleDeclarationError. Both name the command.
  • container is an optional factory that returns an object with get(Type) and close(). It is called only to run a command, never for the list, --help or --version. The command is resolved with container.get(GreetCommand), which builds its constructor collaborators, and the container is closed after the handler returns, throws or is interrupted. Without a factory the command is built with new GreetCommand(), so a command with required collaborators needs one.
  • nodePorts() from @stackra/console/node binds the run to process: its streams, its terminal state, its environment, and SIGINT and SIGTERM. run resolves with the exit code, and you assign it to process.exitCode.
  • createConsole({ ... }) also takes theme, which defaults to STACKRA_THEME.
Running with no command lists the commands by namespace, --help after a command shows its arguments and options, and --version prints the name and version. A misspelt name suggests up to three close names. A name that is a unique abbreviation by segment runs the command, so t:s runs tenant:seed.

Output

io.output writes in the one layout every product shares. Messages go to stdout, except warnings and errors, which go to stderr.
Text is measured by grapheme cluster, so Arabic and emoji columns line up; characters are never reordered, and no bidi isolate marks are written, because many terminals draw them as boxes. Use displayWidth(text) from @stackra/console when you lay out text of your own; isolateBidi(text) wraps right-to-left runs in FSI and PDI for a destination that honours them. On a terminal without bidi or Arabic shaping (TERM_PROGRAM claude-desktop, vscode, iTerm.app, Hyper, Tabby), right-to-left runs are written shaped and in visual order; STACKRA_BIDI=visual|logical overrides the choice, and pipes and --json always get logical text.

Prompts and non-interactive runs

io.prompt asks one question at a time. Each method takes message, and may take default, placeholder, validate and option:
validate(answer) returns a message to refuse a text answer, or undefined to accept it; the prompt stays open until the answer passes. Ctrl-C or Escape in a prompt closes the container and exits 130. A prompt is asked only on a terminal. It is answered by its default instead when any of these holds: stdin or stdout is not a terminal, CI is set, --no-interaction (or -n) is given, or --json is given. A prompt with no default then fails the run with exit code 2, and the message names the option that answers it, so a CI job knows which flag to pass. Give every prompt an option and a default that is safe to use in CI.

Exit codes

JSON output

--json writes exactly one JSON document to stdout. Nothing else reaches stdout: no banner, no text, no prompt, and no colour. Errors are written as the document too, not on stderr.
The data is the object or array the handler returned, or the value of io.output.json(value). A handler that returns a number or nothing has data: null. The error code is the error’s class name in snake case without Error, so NonInteractivePromptError becomes non_interactive_prompt. io.json tells the handler whether --json was given.

Global options

Every command accepts these:

Colour and terminals

Colour depth is decided once per run. It is off when NO_COLOR or CI is set to anything non-empty, when --no-ansi is given, when stdout is not a terminal, or when TERM=dumb. --ansi and FORCE_COLOR turn it on against those. It is 24-bit when COLORTERM is truecolor or 24bit, and 256 colours otherwise. With colour off, the output has no escape sequence and no cursor movement, a spinner is one start line and one end line, and boxes are drawn in ASCII. TERM=linux and a non-UTF-8 locale also draw ASCII symbols.

Themes

A theme is { name, logo, gradient, accent, glyph, asciiGlyph }. The logo is printed with a horizontal gradient across the gradient stops. The layout, the spacing, the symbols and the copy are the same for every theme, so a product changes only these six fields. An optional dark: { gradient, accent } gives the colours on a dark terminal background; the console takes the ground from STACKRA_GROUND=dark|light, then from COLORFGBG, and assumes dark when neither says. STACKRA_THEME is the default, and the package ships no other; on a dark ground it uses the brand’s dark-ground steel (#A7B6C7 to #E2E9F0), still hue-less.
On a light terminal that does not set COLORFGBG (macOS Terminal’s default profile, for one), set STACKRA_GROUND=light in your shell profile so the banner and prompts use the light-ground steel.
A product builds its theme from its brand kit’s colour tokens with themeFromBrandTokens(tokens, choice). tokens is the parsed DTCG colour file, with its colours under palette. choice gives the name, logo, glyphs and the palette token names of the gradient and the accent, and optionally of dark. A missing token, or one that is not #rrggbb, throws a RangeError.
Pass the theme to createConsole({ ..., theme: ACME_THEME }).

Test a command

runCommandForTest from @stackra/console/testing runs one invocation in the test’s process, with memory streams, and returns what it wrote:
  • The second argument is the argv after the program name. The first is a list of command classes, or a whole ConsoleConfig (the same object you pass to createConsole).
  • answers are the scripted answers, in order. Giving them makes the run interactive. A text answer goes through the prompt’s validate, and a run that asks more prompts than there are answers fails naming the prompt.
  • env adds environment variables. NO_COLOR=1 is set unless colour: true, so the output is plain by default. colour: true renders as a true-colour terminal would.
  • columns sets the width (80 by default). container is the factory of createConsole, for fakes. signal ("SIGINT" or "SIGTERM") is sent after the command starts, to test an interruption.
  • The result is { exitCode, stdout, stderr, json, asked }. json is set when all of stdout is one JSON document.

Scaffold a new console app

stackra-console create <directory> writes a standalone console app, with no .stackra/ and no gen. Once @stackra/console is on the Stackra registry:
The scaffolder asks for the app name, the binary name and the accent colour when it is interactive. Otherwise it takes --name, --bin and --accent (#rrggbb), with defaults from the folder name. Its other options:
  • --no-install writes the files and does not install them.
  • --pm pnpm|npm picks the package manager (pnpm by default).
  • --tarballs <dir> installs @stackra/* from packed tarballs in that folder, for checking a release before it is published.
It refuses a folder that exists and is not empty, with exit code 1, and writes nothing. The app it writes has package.json (with the bin and the build, typecheck, test and start scripts), tsconfig.json, tsconfig.build.json, vitest.config.ts, .npmrc (the @stackra registry scope line and no token), .gitignore, README.md, src/main.ts, src/theme.ts, src/commands/greet.command.ts and its test in src/commands/__tests__/. Its greet command has an argument, an option, a prompt, a table and JSON data, and its test covers help, a run, a scripted prompt, NO_COLOR and --json.