burgee 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ import { type CommandNode, type Hook } from './manifest.js';
2
+ /**
3
+ * The plugin contract version. One number for the family — the same `1` flagstaff, caique and
4
+ * closeout declare, written out rather than imported because a layer never imports a layer.
5
+ */
6
+ export declare const CONTRACT = 1;
7
+ /**
8
+ * The keys burgee reads. Declared structurally: any object with these fields is a plugin here,
9
+ * whatever else it carries.
10
+ */
11
+ export interface Plugin {
12
+ name: string;
13
+ contract?: number;
14
+ commands?: CommandNode[];
15
+ hooks?: {
16
+ preRun?: Hook;
17
+ postRun?: Hook;
18
+ onError?: Hook;
19
+ };
20
+ enforce?: 'pre' | 'post';
21
+ }
22
+ export type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT';
23
+ /** A refused plugin says what is wrong and what to do about it — the family's one vocabulary. */
24
+ export declare class PluginError extends Error {
25
+ readonly code: PluginErrorCode;
26
+ readonly fix: string;
27
+ constructor(code: PluginErrorCode, message: string, fix: string);
28
+ }
29
+ /**
30
+ * Refuse a plugin that cannot contribute, at the door.
31
+ *
32
+ * `taken` is the command paths the manifest already serves. A contributed path that is already
33
+ * declared is refused rather than merged, because the two projections disagree about which
34
+ * node wins: `find()` answers the first on a path and `resolve()` the last, so the same
35
+ * command reads one way to help and the other way to dispatch. Which of the two is right for a
36
+ * *first-party* duplicate is a decision about every program rather than about plugins, and is
37
+ * left alone here.
38
+ */
39
+ export declare function validate(plugin: unknown, taken?: readonly string[]): asserts plugin is Plugin;
40
+ /**
41
+ * Declare a plugin: typed, validated, and stamped with the contract it was compiled against.
42
+ *
43
+ * The stamp is the half that makes the refusal in `checkContract` fair. An author who builds
44
+ * against this burgee gets `contract: 1` without typing it, so the only objects that reach
45
+ * `use()` without one are objects built against a burgee that checked nothing — which is
46
+ * exactly the population the version message is addressed to.
47
+ */
48
+ export declare function definePlugin(plugin: Plugin): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,82 @@
1
+ import { checkCommand } from './definition.js';
2
+ export const CONTRACT = 1;
3
+ const UNVALIDATED = '0.6.1';
4
+ export class PluginError extends Error {
5
+ code;
6
+ fix;
7
+ constructor(code, message, fix) {
8
+ super(message);
9
+ this.code = code;
10
+ this.fix = fix;
11
+ this.name = 'PluginError';
12
+ }
13
+ }
14
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
15
+ const ENFORCE = ['pre', 'post'];
16
+ const STAGES = ['preRun', 'postRun', 'onError'];
17
+ const schema = (message, fix) => new PluginError('E_PLUGIN_SCHEMA', message, fix);
18
+ export function validate(plugin, taken = []) {
19
+ if (!isRecord(plugin))
20
+ throw schema('a plugin is a plain object', 'export an object, not a function or an array');
21
+ const name = plugin['name'];
22
+ if (typeof name !== 'string' || name === '') {
23
+ throw schema('a plugin needs a name', 'add `name: "…"` — it is what a contributed command is attributed to');
24
+ }
25
+ checkContract(plugin['contract'], name);
26
+ const enforce = plugin['enforce'];
27
+ if (enforce !== undefined && !ENFORCE.includes(enforce)) {
28
+ throw schema(`plugin "${name}": ${JSON.stringify(enforce)} is not an enforce`, `use ${ENFORCE.join(' or ')}, or leave it out`);
29
+ }
30
+ checkHooks(plugin['hooks'], name);
31
+ checkCommands(plugin['commands'], name, taken);
32
+ }
33
+ function checkContract(contract, name) {
34
+ if (contract === undefined) {
35
+ throw new PluginError('E_PLUGIN_CONTRACT', `plugin "${name}" declares no contract; burgee ${UNVALIDATED} and earlier validated none of it`, `rebuild it against this burgee — \`definePlugin\` from \`burgee/plugin\` stamps \`contract: ${CONTRACT}\` — or add that key by hand`);
36
+ }
37
+ if (!Number.isInteger(contract) || contract < 1 || contract > CONTRACT) {
38
+ throw new PluginError('E_PLUGIN_CONTRACT', `plugin "${name}" declares contract ${String(contract)}; this burgee knows ${CONTRACT}`, 'upgrade burgee, or lower the plugin’s contract');
39
+ }
40
+ }
41
+ function checkHooks(hooks, name) {
42
+ if (hooks === undefined)
43
+ return;
44
+ if (!isRecord(hooks))
45
+ throw schema(`plugin "${name}": hooks must be an object`, 'map a stage to a hook: `{ preRun: { filter?, handler } }`');
46
+ for (const [stage, hook] of Object.entries(hooks)) {
47
+ if (!STAGES.includes(stage))
48
+ throw schema(`plugin "${name}": "${stage}" is not a hook stage`, `use one of ${STAGES.join(', ')}`);
49
+ if (!isRecord(hook) || typeof hook['handler'] !== 'function') {
50
+ throw schema(`plugin "${name}": ${stage} has no handler()`, 'add `handler(ctx)` — without one the TypeError arrives at fire() time, a run later');
51
+ }
52
+ }
53
+ }
54
+ function checkCommands(commands, name, taken) {
55
+ if (commands === undefined)
56
+ return;
57
+ if (!Array.isArray(commands))
58
+ throw schema(`plugin "${name}": commands must be an array`, 'list them in an array of `{ path, options, … }` nodes');
59
+ const seen = new Set(taken);
60
+ for (const [index, node] of commands.entries()) {
61
+ const at = `plugin "${name}": commands[${index}]`;
62
+ if (!isRecord(node) || !Array.isArray(node['path']) || node['path'].length === 0) {
63
+ throw schema(`${at} has no path`, 'add `path: ["…"]` — the words a user types');
64
+ }
65
+ const path = node['path'].join(' ');
66
+ if (seen.has(path)) {
67
+ throw schema(`${at} contributes "${path}", which is already declared`, 'rename it — find() answers the first node on a path and resolve() the last');
68
+ }
69
+ seen.add(path);
70
+ try {
71
+ checkCommand(path, (node['options'] ?? {}), node['effects'], node['run'] !== undefined || node['load'] !== undefined);
72
+ }
73
+ catch (error) {
74
+ throw schema(`${at}: ${error.message}`, 'a plugin command is declared exactly as a first-party one');
75
+ }
76
+ }
77
+ }
78
+ export function definePlugin(plugin) {
79
+ const stamped = { ...plugin, contract: plugin.contract ?? CONTRACT };
80
+ validate(stamped);
81
+ return stamped;
82
+ }
package/dist/runtime.d.ts CHANGED
@@ -35,5 +35,61 @@ export interface Runtime {
35
35
  /** `performance.now` and `setTimeout` in the real runtime; a manual tick in the harness. */
36
36
  clock: Clock;
37
37
  }
38
- /** The one place in the layer that touches `process`. Locked by `process-reference.lock.test.ts`. */
38
+ /**
39
+ * The one place in the layer that names `process` — the whole of Y9. Every other file in
40
+ * this package reads it through `host` or through an injected `Runtime`, which is what
41
+ * `process-reference-lock.test.ts` enforces.
42
+ *
43
+ * **Every member is a getter, and that is the load-bearing decision.** burgee's commander
44
+ * and yargs front-ends do not wrap those packages, they *reproduce their process contracts*,
45
+ * and the incumbents' own suites grade exactly that contract: yargs' swaps `process.argv`,
46
+ * `process.exit` and `process.env` per test; commander's grades `process.argv` when `parse()`
47
+ * is called bare, `process.exit` with no `exitOverride`, and `process.env` for `Option.env()`.
48
+ * A seam that captured those at import — which is what `processRuntime` below does, and what
49
+ * it did for the whole object before this file grew a second export — would hand such a test
50
+ * the value from before its own swap, and the row would drop from 1360 / 804.
51
+ *
52
+ * Getters rather than paratext's `processRuntime()` function shape, because these are read in
53
+ * expression position all over both front-ends (`host.stdout.columns`, `host.env[name]`), and
54
+ * a function would put a call on every one of them; the getter keeps the port reading the way
55
+ * the upstream reads, which is the thing yargs' whole-help-screen assertions compare.
56
+ *
57
+ * Reads are live; *when* a call site reads is unchanged by this file. Two of them capture on
58
+ * purpose and still do — `shim.ts`'s `stdColumns` and `mainFilename` are values in
59
+ * `PlatformShim`, not thunks, and were evaluated at import before the seam existed.
60
+ */
61
+ export declare const host: {
62
+ /** Raw and unsliced: commander and yargs each do their own `from`-dependent slicing. */
63
+ readonly argv: string[];
64
+ readonly env: Record<string, string | undefined>;
65
+ cwd(): string;
66
+ readonly stdin: NodeJS.ReadStream;
67
+ readonly stdout: NodeJS.WriteStream;
68
+ readonly stderr: NodeJS.WriteStream;
69
+ /**
70
+ * The terminal's width, or undefined when there is no terminal to ask. Guarded on
71
+ * `process` itself because cliui's upstream is guarded there: `getWindowWidth` is reached
72
+ * from a bundle that may have no process at all, and a bare read would be a ReferenceError
73
+ * rather than the 80-column fallback.
74
+ */
75
+ readonly columns: number | undefined;
76
+ readonly exitCode: number | string | null | undefined;
77
+ readonly platform: string;
78
+ readonly execPath: string;
79
+ readonly execArgv: string[];
80
+ readonly versions: NodeJS.ProcessVersions;
81
+ /** Electron sets this on the process object; yargs' bin detection asks for it by name. */
82
+ readonly defaultApp: boolean;
83
+ exit(code?: number): never;
84
+ emitWarning(warning: string | Error, type?: string): void;
85
+ nextTick(fn: (...args: unknown[]) => void, ...args: unknown[]): void;
86
+ on(event: string, listener: (...args: unknown[]) => void): void;
87
+ };
88
+ /**
89
+ * The real `Runtime`, for a caller that injects none.
90
+ *
91
+ * `argv`, `env` and `cwd` are getters for the reason the block above gives; `isTTY` is one
92
+ * too, because a stream's `isTTY` is a property of whatever stream is installed *now*, and
93
+ * the harness swaps streams. `stdin`/`stdout`/`stderr` likewise.
94
+ */
39
95
  export declare const processRuntime: Runtime;
package/dist/runtime.js CHANGED
@@ -1,18 +1,87 @@
1
1
  const ARGV_PROGRAM_AND_SCRIPT = 2;
2
+ export const host = {
3
+ get argv() {
4
+ return process.argv;
5
+ },
6
+ get env() {
7
+ return process.env;
8
+ },
9
+ cwd() {
10
+ return process.cwd();
11
+ },
12
+ get stdin() {
13
+ return process.stdin;
14
+ },
15
+ get stdout() {
16
+ return process.stdout;
17
+ },
18
+ get stderr() {
19
+ return process.stderr;
20
+ },
21
+ get columns() {
22
+ if (typeof process === 'undefined')
23
+ return undefined;
24
+ return process.stdout?.columns;
25
+ },
26
+ get exitCode() {
27
+ return process.exitCode;
28
+ },
29
+ get platform() {
30
+ return process.platform;
31
+ },
32
+ get execPath() {
33
+ return process.execPath;
34
+ },
35
+ get execArgv() {
36
+ return process.execArgv;
37
+ },
38
+ get versions() {
39
+ return process.versions;
40
+ },
41
+ get defaultApp() {
42
+ return Reflect.get(process, 'defaultApp') === true;
43
+ },
44
+ exit(code) {
45
+ return process.exit(code);
46
+ },
47
+ emitWarning(warning, type) {
48
+ process.emitWarning(warning, type);
49
+ },
50
+ nextTick(fn, ...args) {
51
+ process.nextTick(fn, ...args);
52
+ },
53
+ on(event, listener) {
54
+ process.on(event, listener);
55
+ },
56
+ };
2
57
  export const processRuntime = {
3
- argv: process.argv.slice(ARGV_PROGRAM_AND_SCRIPT),
4
- env: process.env,
5
- cwd: process.cwd(),
6
- stdin: process.stdin,
7
- stdout: process.stdout,
8
- stderr: process.stderr,
9
- isTTY: {
10
- stdin: Boolean(process.stdin.isTTY),
11
- stdout: Boolean(process.stdout.isTTY),
12
- stderr: Boolean(process.stderr.isTTY),
58
+ get argv() {
59
+ return host.argv.slice(ARGV_PROGRAM_AND_SCRIPT);
60
+ },
61
+ get env() {
62
+ return host.env;
63
+ },
64
+ get cwd() {
65
+ return host.cwd();
66
+ },
67
+ get stdin() {
68
+ return host.stdin;
69
+ },
70
+ get stdout() {
71
+ return host.stdout;
72
+ },
73
+ get stderr() {
74
+ return host.stderr;
75
+ },
76
+ get isTTY() {
77
+ return {
78
+ stdin: Boolean(host.stdin.isTTY),
79
+ stdout: Boolean(host.stdout.isTTY),
80
+ stderr: Boolean(host.stderr.isTTY),
81
+ };
13
82
  },
14
83
  exit(code) {
15
- process.exit(code);
84
+ return host.exit(code);
16
85
  },
17
86
  clock: {
18
87
  now: () => performance.now(),
package/dist/schema.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * manifest and nothing else. Choices are carried as data (N9), the same data that becomes
5
5
  * an MCP tool's input schema.
6
6
  */
7
- import type { ArgumentSpec, CommandNode, Effects, Example, Manifest, OptionSpec } from './manifest.js';
7
+ import { type ArgumentSpec, type CommandNode, type DeclaredEffects, type Example, type Manifest, type OptionSpec } from './manifest.js';
8
8
  export interface JsonSchema {
9
9
  type: 'object';
10
10
  properties: Record<string, JsonSchemaProperty>;
@@ -26,13 +26,29 @@ export interface JsonSchemaProperty {
26
26
  flag?: string;
27
27
  /** The shared set this option was copied from (M4). */
28
28
  sharedFrom?: string;
29
+ /**
30
+ * The flags this option requires, and the flags it excludes — as the caller types them, not
31
+ * as the handler reads them, because the reader of this document is composing a command line
32
+ * (S5). Both are also in the command's `relations`; here they are on the property an agent is
33
+ * already looking at, which is the difference between reading a constraint and finding one.
34
+ *
35
+ * Omitted when the option declares none, so absent reads as "no constraint" rather than
36
+ * "constraints not published" — the rule `relations` follows one level up.
37
+ */
38
+ dependsOn?: string[];
39
+ exclusive?: string[];
29
40
  }
30
41
  export interface CommandSchema {
31
42
  /** The command as typed, without the program name: `config get`. */
32
43
  name: string;
33
44
  description?: string;
34
45
  summary?: string;
35
- effects?: Effects;
46
+ /**
47
+ * What running it does, or `'withheld'` — published either way, because an agent reading
48
+ * the program as data is better served by *this exists and is not for you* than by a gap
49
+ * it cannot tell from a command that does not exist (N6).
50
+ */
51
+ effects?: DeclaredEffects;
36
52
  deprecated?: boolean | string;
37
53
  /** The heading it is listed under (M1). */
38
54
  group?: string;
@@ -102,7 +118,7 @@ export interface SchemaSummary {
102
118
  commands: {
103
119
  name: string;
104
120
  summary?: string;
105
- effects?: Effects;
121
+ effects?: DeclaredEffects;
106
122
  }[];
107
123
  hint: string;
108
124
  }
package/dist/schema.js CHANGED
@@ -1,4 +1,5 @@
1
- import { kebab } from './names.js';
1
+ import { relationsOf } from './manifest.js';
2
+ import { flagsOf, kebab } from './names.js';
2
3
  export const PREDICATE = '(predicate)';
3
4
  function publishable(relation) {
4
5
  if (!('implies' in relation))
@@ -39,6 +40,10 @@ function optionProperty(name, spec) {
39
40
  p.maximum = spec.maximum;
40
41
  if (spec.sharedFrom !== undefined)
41
42
  p.sharedFrom = spec.sharedFrom;
43
+ if (spec.dependsOn !== undefined && spec.dependsOn.length > 0)
44
+ p.dependsOn = flagsOf(spec.dependsOn);
45
+ if (spec.exclusive !== undefined && spec.exclusive.length > 0)
46
+ p.exclusive = flagsOf(spec.exclusive);
42
47
  return p;
43
48
  }
44
49
  export function inputSchemaOf(node) {
@@ -83,8 +88,9 @@ export function commandSchemaOf(node, root) {
83
88
  out.lazy = true;
84
89
  if (node.plugin !== undefined)
85
90
  out.plugin = node.plugin;
86
- if (node.relations !== undefined && node.relations.length > 0)
87
- out.relations = node.relations.map(publishable);
91
+ const relations = relationsOf(node);
92
+ if (relations.length > 0)
93
+ out.relations = relations.map(publishable);
88
94
  return out;
89
95
  }
90
96
  export function runnable(manifest) {
@@ -0,0 +1 @@
1
+ {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/ofri-peretz/burgee/blob/main/packages/flagstaff/src/schema.json","title":"flagstaff plugin","description":"A plugin is one plain object. Everything in it is data that can be read without running it; the only functions allowed are a component's `static` (required) and `frame` (optional). A spinner or component without a static projection is refused at register().","type":"object","required":["name"],"additionalProperties":true,"properties":{"name":{"type":"string","minLength":1,"description":"The plugin's name; also the prefix a host may use when two plugins contribute the same key."},"contract":{"type":"integer","minimum":1,"description":"The plugin contract this object follows. A host refuses a newer contract than it knows."},"tokens":{"type":"object","description":"A roundel theme: semantic token name to a hex colour, contrast-checked when flown.","additionalProperties":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"propertyNames":{"enum":["error","warn","ok","hint","muted","command","flag","value","heading","ground"],"description":"roundel's ten semantic tokens, and nothing else — `roundel`'s own validate() refuses any other name."}},"glyphs":{"type":"object","description":"Symbols by meaning: `ok`, `fail`, `warn`, `info`, `running`. A plugin that ships glyphs changes every built-in that draws one.","additionalProperties":{"type":"string","minLength":1}},"spinners":{"type":"object","description":"Spinner styles by name, in cli-spinners' shape plus the static projection.","additionalProperties":{"$ref":"#/$defs/spinner"}},"borders":{"type":"object","description":"Border styles a box can be drawn with, by name.","additionalProperties":{"$ref":"#/$defs/border"}},"components":{"type":"object","description":"Components by name: `static(state)` returns the text a pipe, an agent or a screen reader gets; `frame(t, state)` is the optional animated form.","additionalProperties":{"$ref":"#/$defs/component"}},"capabilities":{"$ref":"#/$defs/capabilities"}},"$defs":{"spinner":{"type":"object","required":["frames","interval","static"],"properties":{"frames":{"type":"array","items":{"type":"string"},"minItems":1},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between frames on a terminal."},"static":{"type":"string","description":"What a pipe prints instead of the animation."}}},"component":{"type":"object","required":["static"],"properties":{"static":{"description":"(state) => string. Required: the projection every non-terminal mode prints."},"frame":{"description":"(t, state) => string. Optional: the frame at t milliseconds since hoisting."},"sample":{"type":"object","required":["running","done"],"description":"Two states to *show* this component with: `flagstaff check` and the docs gallery render `running` then `done`. Omitted, they assume `{ phase: 'running' }` and `{ phase: 'done' }` and say so in the output. The loop never reads it — a running program's state comes from the program.","properties":{"running":{"description":"The state to open with."},"done":{"description":"The state to close with."}}},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between repaints when `frame` is given; 80 when omitted."}}},"border":{"type":"object","required":["topLeft","top","topRight","left","right","bottomLeft","bottom","bottomRight"],"description":"cli-boxes' shape exactly, so that corpus imports unchanged.","properties":{"topLeft":{"type":"string"},"top":{"type":"string"},"topRight":{"type":"string"},"left":{"type":"string"},"right":{"type":"string"},"bottomLeft":{"type":"string"},"bottom":{"type":"string"},"bottomRight":{"type":"string"}}},"capability":{"type":"object","required":["name","osc","when","encode","fallback"],"additionalProperties":false,"description":"One paratext capability: what it says to the terminal, when the terminal is believed to understand it, and what prints when it does not. No functions, so it travels through JSON. `encode` and `fallback` are templates: `{field}` is the field's value, `{field|base64}` is it base64-encoded, and `[ … ]` is emitted only when every field inside it has a value.","properties":{"name":{"type":"string","minLength":1,"description":"How callers name it. Registering an existing name replaces it — how a caller corrects a guess we got wrong."},"osc":{"description":"The OSC code this speaks, or 'BEL' for the bell and for protocols that are not OSC at all, such as Kitty's.","oneOf":[{"type":"integer","minimum":0},{"const":"BEL"}]},"when":{"type":"object","description":"When the terminal is believed to understand it. Every clause must hold; `termProgram` and `envAny` are ORs within themselves. Guesses — no terminal answers 'do you do OSC 1337' — and therefore data a caller can replace.","additionalProperties":false,"properties":{"tty":{"type":"boolean","description":"Refuse a pipe. Almost always true: a file that receives OSC gets control bytes in it."},"termProgram":{"type":"array","items":{"type":"string"},"description":"Any one of these TERM_PROGRAM values."},"envAny":{"type":"array","items":{"type":"string"},"description":"Any one of these environment variables merely being set, as VTE announces itself."},"term":{"type":"string","description":"An exact TERM — Kitty is xterm-kitty."}}},"encode":{"type":"string","minLength":1,"description":"The bytes, as a template, for a terminal that does understand."},"fallback":{"type":"string","description":"What to print when it does not — PRINCIPLES rule 6. '' is a legitimate answer, a window title having nothing to say in a log; absence is not, which is why this is required and may be ''."}},"examples":[{"name":"kitty-image","osc":"BEL","when":{"tty":true,"term":"xterm-kitty"},"encode":"\u001b_Ga=T,f=100;{base64}\u001b\\","fallback":"{caption}"}]},"capabilities":{"type":"object","description":"paratext capabilities by name — the OSC section of a plugin, read the way flagstaff reads `spinners`.","additionalProperties":{"$ref":"#/$defs/capability"}},"capabilityDocument":{"description":"What paratext's `check()` accepts. Two shapes, and only one of them survives 1.0.","oneOf":[{"description":"The family shape: a plugin carrying its capabilities under `capabilities`.","type":"object","required":["name","capabilities"],"properties":{"name":{"type":"string","minLength":1},"contract":{"type":"integer","minimum":1},"capabilities":{"$ref":"#/$defs/capabilities"}}},{"deprecated":true,"description":"Deprecated: one capability as the whole document, the shape paratext's schema had before this one absorbed it. Accepted for one minor release, removed at 1.0 — `check()` validates it and says so.","$ref":"#/$defs/capability"}]}}}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * E5 and O5 — every door out of a burgee program, bound by the package whose job that is.
3
+ *
4
+ * `exit-code.ts` has declared `SIGINT: 130` and *"SIGINT after the terminal was restored"*
5
+ * since the contract was written, and `.sdlc/intents/burgee/design.md` marks both E5 and O5
6
+ * `R`. Neither was implemented. The engine's only exit was `host.exit(code)` — `process.exit`
7
+ * — which restores nothing, runs nothing, and truncates a pipe by definition (yargs #1519,
8
+ * #2118: *"No truncated JSON"*). A constant is not an implementation.
9
+ *
10
+ * Writing the listener here was the other option and it is the worse one, for a reason this
11
+ * repository can count: `caique`, `flagstaff` and `closeout` each had a copy of "restore the
12
+ * cursor on the way out" before closeout existed, and three copies of a signal handler is
13
+ * three answers to whether Ctrl-C during a spinner leaves the terminal usable. closeout's
14
+ * whole claim is the sentence burgee needs — bound every exit path, run the handlers exactly
15
+ * once, hand the terminal back **last** — and it grades 21/21 against `exit-hook`'s own suite
16
+ * and 6/6 against `restore-cursor`'s.
17
+ *
18
+ * ## The two registries, and why a run must say which it is
19
+ *
20
+ * A run either owns the process or it does not, and the difference is not cosmetic. The
21
+ * harness (`runCommand`), the MCP loop and every façade test inject their own `exit`; a
22
+ * registry that attached nine listeners to the process on their behalf would leak a listener
23
+ * per test and would exit the *test runner* on the first raised signal. So:
24
+ *
25
+ * - `processTeardown` — the real CLI. `install()` wires `exit`, `beforeExit`, five signals,
26
+ * `uncaughtException` and `unhandledRejection`, and burgee's drain goes in the `flush`
27
+ * phase, which closeout runs before `release` and before `restore`.
28
+ * - `detachedTeardown` — a run that owns no process. The same registry with no wiring, run
29
+ * by the engine when the run ends.
30
+ *
31
+ * Both are run by the engine at the end of a run, and closeout's run-once state machine is
32
+ * what makes that safe: a signal that beats the engine to it wins, and the engine's own call
33
+ * finds the work already done rather than doing it twice. That is the property that lets the
34
+ * ordinary path be *awaited* — where `process.exit` would have abandoned an async handler —
35
+ * without giving up the signal path.
36
+ */
37
+ import { type ProcessLike, type Registry } from 'closeout';
38
+ /**
39
+ * The half of a stream a flush needs.
40
+ *
41
+ * Structural, so the engine can hand it `host.stdout` and a test can hand it eight bytes that
42
+ * only come out when somebody waits. `writableLength` is optional because a caller's injected
43
+ * `stdout` is a `{ write }` and has nothing buffered to begin with.
44
+ */
45
+ export interface Drainable {
46
+ writableLength?: number;
47
+ write(chunk: string, callback?: () => void): unknown;
48
+ }
49
+ export interface Teardown {
50
+ /**
51
+ * Register cleanup for this run. Returns the function that unregisters it.
52
+ *
53
+ * `label` is what a breached deadline calls it. Worth passing: the default is the
54
+ * function's own `name`, which is right for `onExit(releaseTheLock)` and `(anonymous)` for
55
+ * the arrow that is the shape that actually hangs.
56
+ */
57
+ add(handler: () => void | Promise<void>, label?: string): () => void;
58
+ /** Run every handler, once, phase by phase, for a run leaving with `code`. */
59
+ run(code: number): Promise<void>;
60
+ /** closeout's registry, for the lock that grades which phase the drain went into. */
61
+ readonly registry: Registry;
62
+ }
63
+ /**
64
+ * The run that owns the process. `proc` is closeout's own seam (its design R7) and is passed
65
+ * only by a test — the memo is skipped for it, so a fake process never becomes the answer a
66
+ * later real run gets.
67
+ */
68
+ export declare function processTeardown(streams: readonly Drainable[], proc?: ProcessLike): Teardown;
69
+ /** A run that owns no process: the harness, the MCP loop, any caller that injected `exit`. */
70
+ export declare function detachedTeardown(streams?: readonly Drainable[]): Teardown;
@@ -0,0 +1,27 @@
1
+ import { createRegistry, install } from 'closeout';
2
+ function drained(stream) {
3
+ if ((stream.writableLength ?? 0) === 0)
4
+ return Promise.resolve();
5
+ return new Promise((done) => {
6
+ stream.write('', () => done());
7
+ });
8
+ }
9
+ function teardownOf(registry, streams) {
10
+ registry.add(async function flushStreams() {
11
+ await Promise.all(streams.map(drained));
12
+ }, { phase: 'flush', label: 'burgee:flush' });
13
+ return {
14
+ add: (handler, label) => registry.add(handler, label === undefined ? 'release' : { phase: 'release', label }),
15
+ run: async (code) => void (await registry.run({ code, signal: null })),
16
+ registry,
17
+ };
18
+ }
19
+ let shared;
20
+ export function processTeardown(streams, proc) {
21
+ if (proc !== undefined)
22
+ return teardownOf(install({ process: proc }).registry, streams);
23
+ return (shared ??= teardownOf(install().registry, streams));
24
+ }
25
+ export function detachedTeardown(streams = []) {
26
+ return teardownOf(createRegistry(), streams);
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import { beforeTerminator, execute } from './execute.js';
3
3
  import { ExitCode, isExitCode } from './exit-code.js';
4
+ import { host } from './runtime.js';
4
5
  export class RuntimeExit extends Error {
5
6
  code;
6
7
  constructor(code) {
@@ -74,14 +75,14 @@ const noop = () => undefined;
74
75
  export function swapEnv(env) {
75
76
  if (!env)
76
77
  return noop;
77
- const saved = { ...process.env };
78
- for (const key of Object.keys(process.env))
79
- delete process.env[key];
80
- Object.assign(process.env, env);
78
+ const saved = { ...host.env };
79
+ for (const key of Object.keys(host.env))
80
+ delete host.env[key];
81
+ Object.assign(host.env, env);
81
82
  return () => {
82
- for (const key of Object.keys(process.env))
83
- delete process.env[key];
84
- Object.assign(process.env, saved);
83
+ for (const key of Object.keys(host.env))
84
+ delete host.env[key];
85
+ Object.assign(host.env, saved);
85
86
  };
86
87
  }
87
88
  const CONSOLE_METHODS = ['log', 'info', 'debug', 'warn', 'error'];
@@ -6,4 +6,5 @@ export declare function singleDashHint(argv: readonly string[]): string | undefi
6
6
  export declare function unknownOption(cause: unknown, declared: readonly string[]): {
7
7
  message: string;
8
8
  hint: string;
9
+ fix?: string;
9
10
  } | undefined;
@@ -23,5 +23,6 @@ export function unknownOption(cause, declared) {
23
23
  return {
24
24
  message: `unknown option ${flag}`,
25
25
  hint: near === undefined ? 'run --help to see the available options' : `did you mean ${near}?`,
26
+ ...(near === undefined ? {} : { fix: near }),
26
27
  };
27
28
  }
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Definition-time checks (S3, S5, V5) and run-time validation in one fixed order (S6):
3
- * relations first, then each option's value — numbers, choices, Standard Schema. Every
4
- * failure is a usage error with the fix in hand (E3).
2
+ * Run-time validation in one fixed order (S6): relations first, then each option's value —
3
+ * numbers, choices, Standard Schema. Every failure is a usage error with the fix in hand (E3).
4
+ *
5
+ * The definition-time checks (S3, S5, V5) moved to `./definition.js` when the plugin host
6
+ * arrived; that file says why. This one runs on every invocation, that one runs once.
5
7
  */
6
8
  import { type OptionSpec, type Relation } from './manifest.js';
7
9
  /** A usage problem the caller can fix, carrying the flag that fixes it (E3). */
@@ -9,11 +11,6 @@ export declare class UsageError extends Error {
9
11
  readonly hint?: string | undefined;
10
12
  constructor(message: string, hint?: string | undefined);
11
13
  }
12
- /**
13
- * What must be true of a declaration before anything runs (yargs #1198, #887, #1679):
14
- * a known type, one short alias per command, no two keys that meet on the command line.
15
- */
16
- export declare function checkDefinition(name: string, options: Record<string, OptionSpec>): void;
17
14
  type Sources = Record<string, {
18
15
  source: string;
19
16
  }>;
package/dist/validate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { kebab } from './names.js';
1
+ import { flagsOf, kebab } from './names.js';
2
2
  export class UsageError extends Error {
3
3
  hint;
4
4
  constructor(message, hint) {
@@ -6,31 +6,8 @@ export class UsageError extends Error {
6
6
  this.hint = hint;
7
7
  }
8
8
  }
9
- const TYPES = new Set(['string', 'boolean', 'number']);
10
- export function checkDefinition(name, options) {
11
- const shorts = new Map();
12
- const flags = new Map();
13
- for (const [key, spec] of Object.entries(options)) {
14
- if (!TYPES.has(spec.type))
15
- throw new Error(`burgee: option "${key}" of "${name}" has unknown type "${String(spec.type)}"`);
16
- if (spec.short !== undefined) {
17
- const owner = shorts.get(spec.short);
18
- if (owner !== undefined)
19
- throw new Error(`burgee: options "${owner}" and "${key}" of "${name}" both use -${spec.short}`);
20
- shorts.set(spec.short, key);
21
- }
22
- const flag = kebab(key);
23
- const clash = flags.get(flag);
24
- if (clash !== undefined)
25
- throw new Error(`burgee: options "${clash}" and "${key}" of "${name}" are both --${flag}`);
26
- flags.set(flag, key);
27
- if ((spec.minimum !== undefined || spec.maximum !== undefined || spec.integer !== undefined) && spec.type !== 'number') {
28
- throw new Error(`burgee: option "${key}" of "${name}" declares a numeric bound but is not a number`);
29
- }
30
- }
31
- }
32
9
  const isSet = (values, key, sources) => values[key] !== undefined && sources[key]?.source !== 'default';
33
- const flagList = (keys) => keys.map((k) => `--${kebab(k)}`).join(', ');
10
+ const flagList = (keys) => flagsOf(keys).join(', ');
34
11
  function exactlyOne(keys, on) {
35
12
  if (on.length === 1)
36
13
  return;
@@ -4,14 +4,14 @@
4
4
  * this module projects that snapshot into the manifest every surface reads (J7, J8).
5
5
  * Nothing here runs at parse time unless a burgee surface was asked for.
6
6
  */
7
- import { type Effects, type Manifest, type OptionSpec } from '../manifest.js';
7
+ import { type DeclaredEffects, type Manifest, type OptionSpec } from '../manifest.js';
8
8
  import type { Positional } from './utils.js';
9
9
  /** What one yargs instance (the root, or a command's builder run on a scratch) registered. */
10
10
  export interface Snapshot {
11
11
  name: string;
12
12
  version?: string | undefined;
13
13
  description?: string | undefined;
14
- effects?: Effects | undefined;
14
+ effects?: DeclaredEffects | undefined;
15
15
  hasHandler: boolean;
16
16
  keys: string[];
17
17
  aliases: Record<string, string[]>;
@@ -1,21 +1,17 @@
1
+ import { type WrapOptions } from "linegauge";
1
2
  /**
2
- * cliui 9 — the column layout yargs' usage renders through — with the string-width,
3
- * strip-ansi and wrap-ansi it depends on, ported for `burgee/yargs`. The wrapping
3
+ * cliui 9 — the column layout yargs' usage renders through — with the wrap-ansi it depends
4
+ * on, ported for `burgee/yargs`. Width and escape-stripping come from linegauge. The wrapping
4
5
  * arithmetic is byte-for-byte the upstream's: yargs' usage tests compare whole help
5
6
  * screens.
6
7
  */
7
- export declare function stripAnsi(str: string): string;
8
- export declare function stringWidth(input: string): number;
9
- interface WrapOptions {
10
- hard?: boolean;
11
- wordWrap?: boolean;
12
- trim?: boolean;
13
- }
8
+ export declare const stripAnsi: (str: string) => string;
9
+ export declare const stringWidth: (input: string) => number;
14
10
  export declare function wrapAnsi(string: string, columns: number, options?: WrapOptions): string;
15
11
  export interface Column {
16
12
  text: string;
17
13
  width?: number | undefined;
18
- align?: 'right' | 'left' | 'center';
14
+ align?: "right" | "left" | "center";
19
15
  padding: number[];
20
16
  border?: boolean;
21
17
  }