burgee
Concepts

The plugin system

One plain object, one published schema, one set of refusal codes, and a check command in every package: how a plugin is written, validated and published across the family.

What it is

A plugin is a plain object with a name. Each package in the family reads its own key of that object and ignores the rest, so one object can extend any subset of the family that is installed, including all of it: tokens for roundel, widths for linegauge, sources for seniority, commands and hooks for burgee, and so on. Plugins has the full key-by-key table, generated from the packages.

Why it exists

Every incumbent extends differently, when it extends at all: a spinner object per ora instance, a border object per boxen call, middleware on one yargs program, nothing for chalk. A company that wants the same colours, glyphs and config source in every CLI it ships has to repeat them per program and per library. One object shape across the family means a convention is written once and shared by every CLI and every layer.

How it works

One schema. The shape is one JSON Schema (draft 2020-12). Its source is packages/flagstaff/src/schema.json; npm run schema:sync copies it byte for byte into every package that hosts plugins, and each publishes it as <package>/schema.json, so an editor, a validator or a model can read it without running anything. name is required, additionalProperties is true — which is what lets a package ignore another package's key — and contract is the revision of the object the plugin was written for, currently 1. Pinned by is byte-identical everywhere it is hosted in plugin-schema-lock.test.ts.

One way in. Eight packages export register(plugin) from <package>/plugin: bellpull, caique, closeout, flagstaff, linegauge, paratext, roundel and seniority. burgee takes the same object through use(plugin) on a program — Manifest#use, and .use() on burgee/commander and burgee/yargs — and definePlugin from burgee/plugin stamps the contract. Each validates the whole object before it keeps anything, so a refused plugin contributes nothing. That the same object registers into every host is one object registers into every host in plugin-contract-lock.test.ts.

One refusal shape. A refusal is a PluginError with a code, a message and a fix — the sentence that turns a refusal into a next step. The codes every host uses are E_PLUGIN_SCHEMA (the object does not match the schema), E_PLUGIN_CONTRACT (a contract newer than the host knows) and E_NO_CONTRIBUTION (it registers, but contributes nothing the host reads). Packages that draw add E_NO_STATIC_PROJECTION — the static projection rule. flagstaff holds the whole vocabulary, and plugin-error-vocabulary-lock.test.ts fails on a code no host's union declares.

The rules

  • A later registration of the same name wins in roundel, caique, paratext, bellpull, flagstaff and linegauge, and the hosts that report contributions say which one it shadowed. closeout's handlers accumulate, ordered by phase. seniority's sources are ordered by rank. burgee refuses a contributed command whose path is already declared.
  • A plugin cannot replace what the host guarantees. caique's six built-in prompt kinds cannot be replaced; closeout refuses a handler in the restore phase; a seniority source's rank must sit strictly between the flag (0) and the default (40); a burgee plugin's commands pass every check a first-party command does, effects included.
  • burgee requires contract. An object that reaches use() without one is refused, because burgee's extension point shipped before it validated anything.
  • A plugin file does not register itself on import. It exports the object, and the program (or check) registers it: refuses a plugin file that registers itself on import, with the code and the fix (R8) in plugin-check-lock.test.ts.
  • The schema cannot say "function". static, run and read are required and described; that each is a function is checked by the package, not the schema.

Validating: <package> check

Every package ships a check command: npx roundel check ./plugin.mjs, and the same for each package. It loads the file, validates and registers the plugin, and prints what that package does with it, ending in <name>: ok. A refusal prints the code and the fix. It exits 0 when the plugin contributes, 1 on a refusal and 2 on a usage error. E_NO_CONTRIBUTION is what catches a misspelled key: token instead of tokens is a plugin that contributes nothing. flagstaff's check also renders each spinner and component in all five output modes, and burgee check returns its report as data, so --json gives an envelope. Pinned for seven hosts by the $name check block of plugin-check-lock.test.ts.

Publishing

A plugin is an ES module whose default export is the object, published like any npm package. There is no plugin registry and no required name prefix: decision D-067 chose a keywords convention over an npm namespace. The object should be plain data wherever the schema allows it, so the published schema.json can describe it without running it.

Run it

One object for three layers:

acme.mjs
export default {
  name: 'acme',
  contract: 1,
  tokens: { ok: '#1a7f37', error: '#cf222e' },
  widths: { powerline: { ranges: [[0xe0b0, 0xe0b3]], columns: 2, why: 'powerline glyphs render wide in the company terminal profile' } },
  sources: { vault: { rank: 25, read: () => ({ region: 'eu-1' }), location: 'vault://acme/cli' } },
};

Each package's check shows only its own part:

npx roundel check acme.mjs
acme — 2 tokens
  ok  #1a7f37
  error  #cf222e
acme: ok
npx linegauge check acme.mjs
acme — 1 widths
  powerline  U+E0B0..U+E0B3  built-in 1 → 2  — powerline glyphs render wide in the company terminal profile
acme: ok

A misspelled key, and a contract from the future:

typo.mjs
export default { name: 'typo', contract: 1, token: { ok: '#1a7f37' } };
npx roundel check typo.mjs
typo — 0 tokens
E_NO_CONTRIBUTION: typo registers, but contributes nothing roundel reads
  fix: add a `tokens` section — a key another package in the family reads is allowed in the same object, but `roundel check` cannot show it
future.mjs
export default { name: 'future', contract: 2, tokens: { ok: '#1a7f37' } };
npx roundel check future.mjs
E_PLUGIN_CONTRACT: plugin "future" declares contract 2; this roundel knows 1
  fix: upgrade roundel, or lower the plugin’s contract

Registered in code, the same object changes what linegauge measures, and a malformed one is refused whole:

widths.mjs
import { width } from 'linegauge';
import { register } from 'linegauge/plugin';

import acme from './acme.mjs';

console.log(width(' main'));
register(acme);
console.log(width(' main'));

try {
  register({ contract: 1, widths: {} });
} catch (error) {
  console.log(`${error.code}: ${error.message}`);
  console.log(`fix: ${error.fix}`);
}
node widths.mjs
6
7
E_PLUGIN_SCHEMA: a plugin needs a non-empty `name`
fix: add `name: "my-widths"` — it is what `linegauge check` and a later registration call it

Where the rules live

On this page