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 incommands/ (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.
- Name.
nameand 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 withConsoleDeclarationError, 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 writeoptional: truefor an optional argument with no default. A@Listargument comes last and takes the rest. - Options bind to
--<kebab-name>, and to-<short>whenshortis given (one letter). Every option is optional. A-n,-h,-V,-vor-qis a global option and cannot be used. - Value kind comes from
@Str,@Int,@Numor@Liston the field (@stackra/validation).@Intand@Numtakeminandmax, 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,
falsein 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--jsondata. Any other return value, or a throw, exits 1.
(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 tocreateConsole and runs them with the process’s ports:
createConsolechecks the registry when it is called: a duplicate name or alias throwsDuplicateCommandError, and a bad declaration throwsConsoleDeclarationError. Both name the command.containeris an optional factory that returns an object withget(Type)andclose(). It is called only to run a command, never for the list,--helpor--version. The command is resolved withcontainer.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 withnew GreetCommand(), so a command with required collaborators needs one.nodePorts()from@stackra/console/nodebinds the run toprocess: its streams, its terminal state, its environment, and SIGINT and SIGTERM.runresolves with the exit code, and you assign it toprocess.exitCode.createConsole({ ... })also takestheme, which defaults toSTACKRA_THEME.
--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.
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.
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 whenNO_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.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.
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 tocreateConsole). answersare the scripted answers, in order. Giving them makes the run interactive. A text answer goes through the prompt’svalidate, and a run that asks more prompts than there are answers fails naming the prompt.envadds environment variables.NO_COLOR=1is set unlesscolour: true, so the output is plain by default.colour: truerenders as a true-colour terminal would.columnssets the width (80 by default).containeris 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 }.jsonis 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:
--name, --bin and --accent (#rrggbb), with defaults from the folder name. Its other options:
--no-installwrites the files and does not install them.--pm pnpm|npmpicks the package manager (pnpm by default).--tarballs <dir>installs@stackra/*from packed tarballs in that folder, for checking a release before it is published.
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.