@sibylline-labs/eris 0.0.0-stage → 0.3.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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +2 -0
  3. package/README.md +146 -2
  4. package/bin/eris.ts +42 -0
  5. package/docs/ARCHITECTURE.md +188 -0
  6. package/docs/COMPLETION.md +106 -0
  7. package/docs/ERRORS.md +632 -0
  8. package/docs/OUTPUT-CONTRACT.md +219 -0
  9. package/docs/README.md +30 -0
  10. package/docs/UPDATE.md +159 -0
  11. package/docs/YAK.md +395 -0
  12. package/docs/dev/ADDING-A-COMMAND.md +79 -0
  13. package/docs/dev/DEVELOPMENT.md +88 -0
  14. package/docs/dev/LAYOUT.md +74 -0
  15. package/docs/dev/PLUGINS.md +382 -0
  16. package/docs/dev/RELEASING.md +157 -0
  17. package/docs/dev/TESTING.md +84 -0
  18. package/docs/dev/surfaces/secrets.md +95 -0
  19. package/package.json +55 -4
  20. package/packages/yak-contract/package.json +28 -0
  21. package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
  22. package/packages/yak-contract/src/index.ts +1 -0
  23. package/packages/yak-contract/src/v2.ts +375 -0
  24. package/plugins/index.ts +12 -0
  25. package/plugins/yak/handlers.ts +128 -0
  26. package/plugins/yak/index.ts +51 -0
  27. package/plugins/yak/login.ts +300 -0
  28. package/plugins/yak/yak-context.ts +280 -0
  29. package/plugins/yak/yak-identity.ts +399 -0
  30. package/plugins/yak/yak-redact.ts +686 -0
  31. package/plugins/yak/yak-setup.ts +133 -0
  32. package/plugins/yak/yak-submit.ts +1166 -0
  33. package/plugins/yak/yak.ts +485 -0
  34. package/src/build.ts +248 -0
  35. package/src/cli/args.ts +434 -0
  36. package/src/cli/commands/completion/bash.ts +24 -0
  37. package/src/cli/commands/completion/domain.ts +203 -0
  38. package/src/cli/commands/completion/emit.ts +246 -0
  39. package/src/cli/commands/completion/shell.ts +24 -0
  40. package/src/cli/commands/completion/zsh.ts +24 -0
  41. package/src/cli/commands/completion.ts +48 -0
  42. package/src/cli/commands/update/github.ts +328 -0
  43. package/src/cli/commands/update/opt-in.ts +32 -0
  44. package/src/cli/commands/update/refusal.ts +21 -0
  45. package/src/cli/commands/update/release.ts +351 -0
  46. package/src/cli/commands/update/replace.ts +143 -0
  47. package/src/cli/commands/update/semver.ts +48 -0
  48. package/src/cli/commands/update.ts +47 -0
  49. package/src/cli/commands/usage.ts +68 -0
  50. package/src/cli/commands/version.ts +29 -0
  51. package/src/cli/compose.ts +273 -0
  52. package/src/cli/output.ts +81 -0
  53. package/src/cli/plugin.ts +85 -0
  54. package/src/cli/registry.ts +180 -0
  55. package/src/cli/router.ts +31 -0
  56. package/src/cli/ts-verb.ts +126 -0
  57. package/src/main.ts +223 -0
  58. package/src/runtime/env.ts +16 -0
  59. package/src/runtime/eris-assets.d.ts +5 -0
  60. package/src/runtime/home.ts +306 -0
  61. package/src/runtime/logging.ts +43 -0
  62. package/src/runtime/process.ts +388 -0
  63. package/src/runtime/settings.ts +191 -0
  64. package/src/sdk.ts +62 -0
  65. package/testing/build-child.ts +55 -0
  66. package/testing/fake-runner.ts +23 -0
  67. package/testing/index.ts +16 -0
  68. package/ts/lib/eris-fail.ts +180 -0
  69. package/ts/lib/pool.ts +63 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * compose.ts — core rows plus plugin rows, merged once, before anything is
3
+ * derived from them.
4
+ *
5
+ * `composeCli` is called by a distribution's entry (src/main.ts composes the
6
+ * build's list, plugins/index.ts). Its result, a `Cli`, is what the router,
7
+ * the `--output json` allow-list, completion and the top-level help read, so a
8
+ * plugin's rows reach every plane a core row does with no core edit.
9
+ *
10
+ * A plugin that would break a plane is refused here, at composition, which
11
+ * runs when the build validates its plugin list (src/build.ts) and when the
12
+ * program starts:
13
+ *
14
+ * - a group that collides with a built-in, an argv shape or another plugin;
15
+ * - anything but a `ts` implementation (a row from untyped code can name
16
+ * one the type does not allow, and no other runs);
17
+ * - a sub row with no parent, a group with no handler, a handler with no group;
18
+ * - `json: true` on a group that has subs: the allow-list would accept every
19
+ * sub, including one added later with no machine rendering. Each sub that
20
+ * renders JSON says so on its own row, so the allow-list fails closed;
21
+ * - a completion slot the emitted script cannot hold (`complete` on a row, core's
22
+ * or a plugin's): a word that is not shell-safe, a domain helper or library text that fails
23
+ * src/cli/commands/completion/domain.ts, or one domain (or one helper
24
+ * function) given two different texts across the build;
25
+ * - a `menu` that is not a handler thunk, or a `completionNote` that is not
26
+ * one or more non-empty lines of printable ASCII.
27
+ *
28
+ * A distribution may own the bare program (`eris` with no arguments): its
29
+ * `menu` handler replaces core's usage there; `-h` and `--help` stay the usage,
30
+ * and say that bare `eris` is interactive. It may also add a paragraph to
31
+ * `eris completion --help` (`completionNote`).
32
+ *
33
+ * Entitlements are not composition: a `Cli` says which product each plugin
34
+ * group belongs to, and `lockedProduct` checks a workspace's entitlements
35
+ * against it per command line.
36
+ */
37
+
38
+ import type { VerbHandler } from "../main";
39
+ import { helpText } from "./args";
40
+ import { renderFailureBlock } from "../../ts/lib/eris-fail";
41
+ import { domainFunction, helperTexts, scanHelper, slotWordRefusal } from "./commands/completion/domain";
42
+ import { jsonFixLine, unsupportedFix } from "./output";
43
+ import type { ErisPlugin } from "./plugin";
44
+ import { ARGV_SHAPES, deriveJsonSurfaces, deriveTable, type Impl, type Row } from "./registry";
45
+
46
+ type Rows = Readonly<Record<string, Row>>;
47
+ type Handlers = Readonly<Record<string, () => Promise<VerbHandler>>>;
48
+
49
+ export class PluginCompositionError extends Error {
50
+ override name = "PluginCompositionError";
51
+ }
52
+
53
+ export interface PluginGroup {
54
+ readonly group: string;
55
+ readonly summary: string;
56
+ readonly plugin: string;
57
+ readonly product?: string;
58
+ }
59
+
60
+ export interface Cli {
61
+ /** Core rows and every plugin's rows. */
62
+ readonly registry: Rows;
63
+ readonly table: Readonly<Record<string, Impl>>;
64
+ readonly handlers: Handlers;
65
+ readonly jsonSurfaces: Readonly<Record<string, true | readonly string[]>>;
66
+ /** The `output-json-unsupported` fix text: core's, with each plugin's `jsonFix`, or a line per json row, in its plugin slot. */
67
+ readonly jsonFix: string;
68
+ /** The plugin groups, in composition order. */
69
+ readonly groups: readonly PluginGroup[];
70
+ /** Whether the bare program is a distribution's menu: the top-level usage then points at it. */
71
+ readonly hasMenu: boolean;
72
+ /** The distribution's paragraph of `eris completion --help` (`ComposeOptions.completionNote`), or null. */
73
+ readonly completionNote: string | null;
74
+ }
75
+
76
+ /** A composed CLI and the workspace's entitlements: what one command line runs against. */
77
+ export interface Session {
78
+ readonly cli: Cli;
79
+ readonly entitled: ReadonlySet<string>;
80
+ }
81
+
82
+ /** The entitlements a command line has when its caller supplies none: no
83
+ * product, so every product-gated plugin group is locked. */
84
+ export const NO_ENTITLEMENTS: ReadonlySet<string> = new Set();
85
+
86
+ const COMMAND_NAME = /^[a-z][a-z0-9-]*$/;
87
+ /** Lines of printable ASCII, none empty, as `eris completion --help` prints them. */
88
+ const COMPLETION_NOTE = /^[\x20-\x7e]+(?:\n[\x20-\x7e]+)*$/;
89
+
90
+ export interface ComposeOptions {
91
+ /** The bare program's handler (`eris` with no arguments); core's usage when absent.
92
+ * The top-level usage then also says `Run 'eris' without arguments for interactive mode.` */
93
+ readonly menu?: () => Promise<VerbHandler>;
94
+ /** A paragraph `eris completion --help` prints after core's description: what
95
+ * this distribution's completion domains offer. One or more lines of printable
96
+ * ASCII, none empty, no trailing newline. */
97
+ readonly completionNote?: string;
98
+ }
99
+
100
+ /** The completion domains a build has seen, so one name or function keeps one text. */
101
+ interface DomainLedger {
102
+ readonly domains: Map<string, { readonly texts: readonly string[]; readonly by: string }>;
103
+ readonly functions: Map<string, { readonly text: string; readonly by: string }>;
104
+ }
105
+
106
+ /** Refuses a row whose `complete` slots the emitted script cannot hold. `by` names the row's owner in a later refusal. */
107
+ function checkSlots(path: string, row: Row, by: string, ledger: DomainLedger, refuse: (what: string) => never): void {
108
+ (row.complete ?? []).forEach((slot, n) => {
109
+ for (const word of slot) {
110
+ const why = slotWordRefusal(word);
111
+ if (why !== null) refuse(`row '${path}' slot ${n}: ${why}`);
112
+ if (typeof word === "string") continue;
113
+ const d = `domain '${word.domain}'`;
114
+ const texts = helperTexts(word);
115
+ const prior = ledger.domains.get(word.domain);
116
+ if (prior !== undefined) {
117
+ if (prior.texts.join("\0") !== texts.join("\0")) refuse(`${d}: it carries a different helper or library from ${prior.by}`);
118
+ continue;
119
+ }
120
+ const defined: string[] = [];
121
+ texts.forEach((text, k) => {
122
+ const what = k === texts.length - 1 ? "its helper" : `its library text ${k}`;
123
+ const scan = scanHelper(text);
124
+ if (!scan.ok) return refuse(`${d}: ${what} ${scan.why}`);
125
+ if (k === texts.length - 1 && scan.function !== domainFunction(word.domain)) refuse(`${d}: its helper defines ${scan.function}, not ${domainFunction(word.domain)}`);
126
+ if (k < texts.length - 1 && scan.function === domainFunction(word.domain)) refuse(`${d}: its library defines ${scan.function}, which is its helper's`);
127
+ const other = ledger.functions.get(scan.function);
128
+ if (other !== undefined && other.text !== text) refuse(`${d}: ${what} defines '${scan.function}', which ${other.by} defines with other text`);
129
+ defined.push(scan.function);
130
+ });
131
+ texts.forEach((text, k) => ledger.functions.set(defined[k]!, { text, by }));
132
+ ledger.domains.set(word.domain, { texts, by });
133
+ }
134
+ });
135
+ }
136
+
137
+ export function composeCli(coreRows: Rows, coreHandlers: Handlers, plugins: readonly ErisPlugin[], opts: ComposeOptions = {}): Cli {
138
+ const registry: Record<string, Row> = { ...coreRows };
139
+ const handlers: Record<string, () => Promise<VerbHandler>> = { ...coreHandlers };
140
+ if (opts.menu !== undefined) {
141
+ if (typeof opts.menu !== "function") throw new PluginCompositionError("menu must be a handler thunk, () => Promise<VerbHandler>");
142
+ handlers[""] = opts.menu;
143
+ }
144
+ if (opts.completionNote !== undefined && (typeof opts.completionNote !== "string" || !COMPLETION_NOTE.test(opts.completionNote))) {
145
+ throw new PluginCompositionError("completionNote must be one or more non-empty lines of printable ASCII");
146
+ }
147
+ const owner = new Map<string, string | null>();
148
+ for (const k of [...Object.keys(ARGV_SHAPES), ...Object.keys(coreRows).filter((k) => !k.includes(" "))]) owner.set(k, null);
149
+ const names = new Set<string>();
150
+ const groups: PluginGroup[] = [];
151
+ const fixLines: string[] = [];
152
+ const ledger: DomainLedger = { domains: new Map(), functions: new Map() };
153
+ const refuseCore = (what: string): never => {
154
+ throw new PluginCompositionError(`core: ${what}`);
155
+ };
156
+ for (const [path, row] of Object.entries(coreRows)) checkSlots(path, row, "core", ledger, refuseCore);
157
+
158
+ for (const p of plugins) {
159
+ const refuse = (what: string): never => {
160
+ throw new PluginCompositionError(`plugin '${p.name}': ${what}`);
161
+ };
162
+ if (names.has(p.name)) throw new PluginCompositionError(`plugin '${p.name}' is composed twice`);
163
+ names.add(p.name);
164
+ const paths = Object.keys(p.rows);
165
+ const own = paths.filter((k) => !k.includes(" "));
166
+ const generated: string[] = [];
167
+
168
+ for (const g of own) {
169
+ if (!COMMAND_NAME.test(g)) refuse(`group '${g}' is not a command name (lower-case letters, digits, '-')`);
170
+ if (owner.has(g)) {
171
+ const prior = owner.get(g);
172
+ refuse(`group '${g}' collides with ${prior === null ? "a built-in command" : `plugin '${prior}'`}`);
173
+ }
174
+ if (p.rows[g]!.impl !== "ts") refuse(`group '${g}' must be impl 'ts'`);
175
+ if (!Object.hasOwn(p.handlers, g)) refuse(`group '${g}' has no handler`);
176
+ }
177
+ for (const path of paths) {
178
+ const row = p.rows[path]!;
179
+ const words = path.split(" ");
180
+ if (words.length > 1) {
181
+ const parent = words.slice(0, -1).join(" ");
182
+ if (!Object.hasOwn(p.rows, parent)) refuse(`row '${path}' hangs off '${parent}', which the plugin does not declare`);
183
+ if (row.impl !== undefined && row.impl !== "ts") refuse(`row '${path}' must not name an impl other than 'ts'`);
184
+ }
185
+ checkSlots(path, row, `plugin '${p.name}'`, ledger, refuse);
186
+ if (row.json === true) {
187
+ if (paths.some((k) => k.startsWith(`${path} `))) refuse(`row '${path}' has subs, so json goes on each sub that renders it`);
188
+ generated.push(jsonFixLine(path, row.summary));
189
+ }
190
+ }
191
+ for (const h of Object.keys(p.handlers)) if (!own.includes(h)) refuse(`handler '${h}' has no group row`);
192
+ if (p.jsonFix !== undefined) {
193
+ if (generated.length === 0) refuse("jsonFix is set but no row says json: true");
194
+ if (!p.jsonFix.endsWith("\n")) refuse("jsonFix must end with a newline");
195
+ fixLines.push(p.jsonFix);
196
+ } else fixLines.push(...generated);
197
+
198
+ for (const g of own) {
199
+ owner.set(g, p.name);
200
+ groups.push({ group: g, summary: p.rows[g]!.summary, plugin: p.name, ...(p.product !== undefined ? { product: p.product } : {}) });
201
+ }
202
+ Object.assign(registry, p.rows);
203
+ Object.assign(handlers, p.handlers);
204
+ }
205
+
206
+ return {
207
+ registry,
208
+ table: deriveTable(registry),
209
+ handlers,
210
+ jsonSurfaces: deriveJsonSurfaces(registry),
211
+ jsonFix: unsupportedFix(fixLines),
212
+ groups,
213
+ hasMenu: opts.menu !== undefined,
214
+ completionNote: opts.completionNote ?? null,
215
+ };
216
+ }
217
+
218
+ /** The product `group` needs and the session has not bought, else undefined. */
219
+ export function lockedProduct(session: Session, group: string): string | undefined {
220
+ const product = session.cli.groups.find((g) => g.group === group)?.product;
221
+ return product !== undefined && !session.entitled.has(product) ? product : undefined;
222
+ }
223
+
224
+ const lockedWhy = (product: string) => `part of the '${product}' product, which this workspace has not bought`;
225
+
226
+ /** The top-level help's command lines: each core group, then each plugin
227
+ * group (a locked one marked), every summary starting in one column. */
228
+ export function commandLines(session: Session): string {
229
+ const { registry, groups } = session.cli;
230
+ const plugin = new Set(groups.map((g) => g.group));
231
+ const core = Object.keys(registry)
232
+ .filter((k) => !k.includes(" ") && !plugin.has(k))
233
+ .map((group) => ({ group, summary: registry[group]!.summary }));
234
+ const rows = [...core, ...groups];
235
+ const width = Math.max(...rows.map((r) => r.group.length));
236
+ return rows
237
+ .map((r) => {
238
+ const product = lockedProduct(session, r.group);
239
+ return ` ${r.group.padEnd(width)} ${r.summary}${product !== undefined ? ` [locked: ${product}]` : ""}\n`;
240
+ })
241
+ .join("");
242
+ }
243
+
244
+ /**
245
+ * What a locked command line prints. Help (`--help` ahead of any `--`) is the
246
+ * deepest row's generated help plus why it is locked, on stdout, exit 0: it
247
+ * reads the rows only, never the plugin's handler. Anything else is the
248
+ * `command-locked` block on stderr, exit 1.
249
+ */
250
+ export function lockedOutcome(session: Session, product: string, words: readonly string[], help: boolean, home: string): { stream: "stdout" | "stderr"; text: string; rc: number } {
251
+ const group = words[0]!;
252
+ if (help) {
253
+ let path = group;
254
+ for (const w of words.slice(1)) {
255
+ if (!Object.hasOwn(session.cli.registry, `${path} ${w}`)) break;
256
+ path = `${path} ${w}`;
257
+ }
258
+ return { stream: "stdout", text: `${helpText(path, session.cli.registry[path]!)}\nLocked: ${lockedWhy(product)}.\n`, rc: 0 };
259
+ }
260
+ return {
261
+ stream: "stderr",
262
+ rc: 1,
263
+ text: renderFailureBlock(
264
+ {
265
+ slug: "command-locked",
266
+ what: `'eris ${group}' is ${lockedWhy(product)}`,
267
+ fix: `add the '${product}' product to this workspace, then run the command again`,
268
+ docs: "docs/ERRORS.md#command-locked",
269
+ },
270
+ home,
271
+ ),
272
+ };
273
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * output.ts — the output contract's checks: which surfaces accept
3
+ * `--output json`, the refusal for one that does not, and how a value is
4
+ * echoed into a diagnostic. The global `--output` parse itself is argv
5
+ * parsing, in src/cli/args.ts.
6
+ *
7
+ * The refusal blocks are the contract in docs/OUTPUT-CONTRACT.md §2–§4;
8
+ * `tests/cli-router.test.ts` holds them against the earlier Bash
9
+ * implementation's answers, frozen in
10
+ * tests/fixtures/yaml-parity/oracle-goldens.json.
11
+ */
12
+
13
+ import { renderFailureBlock } from "../../ts/lib/eris-fail";
14
+ import { deriveJsonSurfaces } from "./registry";
15
+
16
+ export type OutputFormat = "text" | "json";
17
+
18
+ /** A value echoed into a diagnostic: C0 controls except tab and newline, plus
19
+ * DEL, removed; then every trailing newline dropped. */
20
+ export function sanitizeDisplay(s: string): string {
21
+ return s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").replace(/\n+$/, "");
22
+ }
23
+
24
+ /**
25
+ * The surfaces with a machine rendering: a group maps to `true` (every sub)
26
+ * or to the subs that qualify. Derived from the registry's `json` rows;
27
+ * `unsupportedJsonBlock` is its only reader.
28
+ */
29
+ export const JSON_SURFACES: Readonly<Record<string, true | readonly string[]>> = deriveJsonSurfaces();
30
+
31
+ /** The column a json surface's shape starts at in the refusal's fix text. */
32
+ export const FIX_COLUMN = 43;
33
+
34
+ /** One line of the refusal's fix text: ` eris <path>`, then from `FIX_COLUMN` what it prints. */
35
+ export function jsonFixLine(path: string, shape: string): string {
36
+ const head = ` eris ${path}`;
37
+ return `${head}${" ".repeat(Math.max(1, FIX_COLUMN - head.length))}${shape}\n`;
38
+ }
39
+
40
+ /** The refusal's fix text: this header and core's own json surfaces, then each
41
+ * plugin's lines (src/cli/compose.ts): the plugin's own `jsonFix`, else a
42
+ * line per json row. A json row or argv shape added to core is named here too
43
+ * (tests/cli-router.test.ts holds the two together). */
44
+ export const UNSUPPORTED_FIX_SURFACES = `the surfaces that accept --output json are:
45
+ ${jsonFixLine("--version", "{version, release_repo, build_id, extra}")}${jsonFixLine("update", "{current, latest, update_available, updated}")}`;
46
+
47
+ /** The fix text with `lines` (each ending in a newline) after the header. The
48
+ * last newline is dropped: a trailing one renders as an empty `fix:` line. */
49
+ export function unsupportedFix(lines: readonly string[] = []): string {
50
+ return `${UNSUPPORTED_FIX_SURFACES}${lines.join("")}`.replace(/\n$/, "");
51
+ }
52
+
53
+ export const UNSUPPORTED_FIX = unsupportedFix();
54
+
55
+ /** `output::assert_supported`. Returns the refusal block, or null. A composed
56
+ * build passes its own allow-list and fix text (src/cli/compose.ts). */
57
+ export function unsupportedJsonBlock(
58
+ group: string | undefined,
59
+ sub: string | undefined,
60
+ format: OutputFormat,
61
+ home: string,
62
+ surfaces: Readonly<Record<string, true | readonly string[]>> = JSON_SURFACES,
63
+ fix: string = UNSUPPORTED_FIX,
64
+ ): string | null {
65
+ if (format !== "json") return null;
66
+ const g = group ?? "";
67
+ const s = sub ?? "";
68
+ const surface = Object.hasOwn(surfaces, g) ? surfaces[g] : undefined;
69
+ if (surface === true || (surface && surface.includes(s))) return null;
70
+ return renderFailureBlock(
71
+ {
72
+ slug: "output-json-unsupported",
73
+ what:
74
+ `'eris ${sanitizeDisplay(`${g}${s !== "" ? ` ${s}` : ""}`)}' has no JSON rendering — ` +
75
+ "refusing --output json rather than printing human text a parser would read as JSON",
76
+ fix,
77
+ docs: "docs/OUTPUT-CONTRACT.md#3-machine-surfaces",
78
+ },
79
+ home,
80
+ );
81
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * plugin.ts — the plugin SDK: the contract a plugin is written against, and
3
+ * the one eris module a plugin imports (the `plugin-imports-sdk-only`
4
+ * ast-grep rule in rules/ holds it).
5
+ *
6
+ * A plugin is a package exporting registry rows plus handlers:
7
+ *
8
+ * - `rows`: pure data, the same `Row` shape as src/cli/registry.ts. A group
9
+ * row (one word) says `impl: "ts"`; sub rows hang off it. The router table,
10
+ * the argv parse, the `--output json` allow-list, completion and the
11
+ * top-level help all derive from them once composed (src/cli/compose.ts).
12
+ * - `handlers`: one thunk per group, `async () => (await import("./x")).run`.
13
+ * The manifest is read on every command line, `eris completion` included,
14
+ * and a thunk is called only on dispatch, so plugin code never loads at
15
+ * shell start (`plugin-manifest-lazy-handlers`).
16
+ * - `product`: absent for a plugin every workspace gets; else the product a
17
+ * workspace must have bought, without which its commands render as locked.
18
+ *
19
+ * Composition is build-time: a distribution's entry composes core with its
20
+ * plugin list and hands the result to `runProgram` (src/main.ts). There is no
21
+ * runtime plugin loader. docs/dev/PLUGINS.md is the authoring guide.
22
+ *
23
+ * Everything here is on the completion path through the manifests: it imports
24
+ * only modules that path already loads.
25
+ */
26
+
27
+ import type { CliContext, VerbContext, VerbHandler } from "../main";
28
+ import { type ArgSinks, type GroupArgs, groupArgsIn, type ReadOptions, readRowArgs, type RowArgs } from "./args";
29
+ import type { CompletionDomain, Flag, FlagName, Row, SlotWord } from "./registry";
30
+
31
+ export { erisHome, renderFailureBlock, renderWarnLines } from "../../ts/lib/eris-fail";
32
+ export { readEnv } from "../runtime/env";
33
+ export { CliExit } from "./args";
34
+ export type { ArgSinks, CliContext, CompletionDomain, Flag, FlagName, GroupArgs, ReadOptions, Row, RowArgs, SlotWord, VerbContext, VerbHandler };
35
+
36
+ type Rows = Readonly<Record<string, Row>>;
37
+
38
+ /** The one-word keys of a rows table: its groups. */
39
+ export type GroupsOf<R> = { [K in keyof R & string]: K extends `${string} ${string}` ? never : K }[keyof R & string];
40
+
41
+ export interface ErisPlugin<R extends Rows = Rows> {
42
+ /** Unique across a build; named in every composition refusal. */
43
+ readonly name: string;
44
+ readonly product?: string;
45
+ readonly rows: R;
46
+ readonly handlers: Readonly<Record<string, () => Promise<VerbHandler>>>;
47
+ /**
48
+ * The plugin's lines in the `output-json-unsupported` fix text, written
49
+ * like core's (src/cli/output.ts): ` eris <verb>`, then from column 43 the
50
+ * JSON shape it prints, each line ending in a newline. Absent, compose
51
+ * writes one line per json row from its summary.
52
+ */
53
+ readonly jsonFix?: string;
54
+ }
55
+
56
+ /** A plugin, with a handler required for each group its rows declare. */
57
+ export function definePlugin<const R extends Rows>(plugin: {
58
+ readonly name: string;
59
+ readonly product?: string;
60
+ readonly rows: R;
61
+ readonly handlers: { readonly [G in GroupsOf<R>]: () => Promise<VerbHandler> };
62
+ readonly jsonFix?: string;
63
+ }): ErisPlugin<R> {
64
+ return plugin;
65
+ }
66
+
67
+ /**
68
+ * A handler's argv, read from its row: the row's flags and operands exactly,
69
+ * typed from the row's literal declaration. Help goes to stdout and returns
70
+ * null (the handler returns 0); a refusal goes to stderr, then `CliExit(1)`.
71
+ */
72
+ export function readArgs<R extends Rows, P extends keyof R & string>(
73
+ rows: R,
74
+ path: P,
75
+ argv: readonly string[],
76
+ io: ArgSinks,
77
+ opts: ReadOptions = {},
78
+ ): RowArgs<R[P]> | null {
79
+ return readRowArgs(path, rows[path]!, argv, io, opts);
80
+ }
81
+
82
+ /** A group handler's first word: `bare`, `help`, one of the group's subs, or `unknown`. */
83
+ export function groupArgs<R extends Rows>(rows: R, group: GroupsOf<R>, argv: readonly string[]) {
84
+ return groupArgsIn(rows, group, argv);
85
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * registry.ts — every eris verb, once.
3
+ *
4
+ * A row is keyed by its command path: a group (`"completion"`), a group and a
5
+ * sub (`"completion bash"`), or a nested sub (`"group sub nested"`). A group
6
+ * row names its implementation; a sub row inherits its parent's unless it names
7
+ * its own. The one implementation is `ts`, which runs in-process; a row from
8
+ * untyped code naming any other is refused at composition (src/cli/compose.ts).
9
+ * `flags` are the long flags the verb accepts, `--help`
10
+ * aside (every verb takes it); `pos` its positional operands. `json` marks a
11
+ * surface that accepts the global `--output json`.
12
+ *
13
+ * Read by: the argv parser (src/cli/args.ts — a TS verb's flags and operands
14
+ * are exactly its row's, and its handler's argument type is derived from the
15
+ * row), the router (`TABLE`, src/cli/router.ts), the `--output json`
16
+ * allow-list (src/cli/output.ts),
17
+ * and the completion emitter (src/cli/commands/completion/emit.ts), which
18
+ * generates the shell tab-completion tree from these rows.
19
+ *
20
+ * This module is on the path of every command line, the shell-start
21
+ * completion run included: it imports nothing.
22
+ */
23
+
24
+ export type Impl = "ts";
25
+
26
+ export type FlagName = `--${string}`;
27
+
28
+ export interface Flag {
29
+ readonly name: FlagName;
30
+ readonly takesValue: boolean;
31
+ /** A value flag that may be given more than once; its values are kept in order. */
32
+ readonly repeatable?: boolean;
33
+ /** A one-letter spelling the parser also accepts (`-y` for `--yes`). */
34
+ readonly alias?: `-${string}`;
35
+ /** A value flag whose value is the next operand rather than the next word: a
36
+ * declared flag may sit between (`--from --yes f`), and a dash-led word
37
+ * directly after it is the value (`--from -f`). */
38
+ readonly deferredValue?: boolean;
39
+ /** A value flag whose value is a file name: tab completion offers file names
40
+ * after it. Read from the composed rows, so a plugin declares its own. */
41
+ readonly file?: boolean;
42
+ }
43
+
44
+ export interface Row {
45
+ readonly summary: string;
46
+ /** Required on a group row; a sub row inherits its parent's. */
47
+ readonly impl?: Impl;
48
+ readonly flags?: readonly Flag[];
49
+ readonly json?: boolean;
50
+ /** The positional operands of a TS leaf, in order: `name`, `name?` (optional),
51
+ * `name...` (every remaining word). A leaf without `pos` takes none, and a
52
+ * word past the last is refused (src/cli/args.ts). */
53
+ readonly pos?: readonly string[];
54
+ /** The usage line after `eris <path> `, quoted in every argument refusal and
55
+ * heading the generated help. Absent: generated from `pos` and `flags`. */
56
+ readonly usage?: string;
57
+ /** Said in the extra-word refusal, e.g. "quote the statement". */
58
+ readonly extraHint?: string;
59
+ /** Tab completion for the positional operands, one entry per slot, in
60
+ * order: the words a slot offers. A word is a literal, `@file` (file
61
+ * names: the slot offers nothing else), or a `CompletionDomain`. A slot
62
+ * past the last entry completes nothing. Checked at composition. */
63
+ readonly complete?: readonly (readonly SlotWord[])[];
64
+ }
65
+
66
+ /**
67
+ * A slot whose words only a shell function can list at tab-press time: names
68
+ * the distribution keeps in a file, say. `helper` is shell text, bash-3.2
69
+ * clean (the zsh route loads the same body), holding exactly one function
70
+ * definition, `__eris_dom_<name>` for `@<name>`. That function is called with
71
+ * the words typed in the earlier positional slots and prints its words;
72
+ * `compgen -W` expands them, so it must print shell-safe words only. Functions
73
+ * it calls go in `library`, one definition per text, each `__eris_dom_…`;
74
+ * domains that share a library hand over the same texts, emitted once.
75
+ *
76
+ * The texts bypass the emitter's word checks, so composition checks them
77
+ * instead (src/cli/commands/completion/domain.ts). They are trusted like a
78
+ * plugin's handler code: the check keeps a mistake from breaking or running
79
+ * code in every shell that sources `eris completion`, not a hostile author out.
80
+ */
81
+ export interface CompletionDomain {
82
+ /** `@` then lower-case letters, digits, `_`; `@file` is built in. */
83
+ readonly domain: `@${string}`;
84
+ readonly helper: string;
85
+ readonly library?: readonly string[];
86
+ }
87
+
88
+ export type SlotWord = string | CompletionDomain;
89
+
90
+ /** Every verb, as declared: the literal types the parser derives a handler's
91
+ * argument type from. `REGISTRY` is the same object for code that walks it.
92
+ * Core declares `completion` and `update`; every other group is a plugin's
93
+ * (plugins/index.ts, composed by src/cli/compose.ts). */
94
+ export const VERBS = {
95
+ completion: { impl: "ts", summary: "Emit shell tab-completion script (bash, zsh)" },
96
+ "completion bash": { summary: "Emit the bash completion script" },
97
+ "completion zsh": { summary: "Emit the zsh completion script" },
98
+ update: { impl: "ts", summary: "Print how to get the latest eris release (npx, bunx)", json: true },
99
+ } as const satisfies Readonly<Record<string, Row>>;
100
+
101
+ export type VerbPath = keyof typeof VERBS;
102
+
103
+ export const REGISTRY: Readonly<Record<string, Row>> = VERBS;
104
+
105
+ /** The argv shapes that are not verbs: the bare program, the top-level
106
+ * help and the version. */
107
+ export const ARGV_SHAPES: Readonly<Record<string, Impl>> = { "": "ts", "-h": "ts", "--help": "ts", "--version": "ts" };
108
+
109
+ /** The argv shapes that render JSON under the global `--output`. */
110
+ export const ARGV_SHAPE_JSON: readonly string[] = ["--version"];
111
+
112
+ /** The argv shapes, beyond a `--help`, that print from the binary alone and
113
+ * read nothing from the install root. The bare program is not one: a
114
+ * distribution's menu may read it. */
115
+ export const ARGV_SHAPES_NO_ASSETS: readonly string[] = ["--version"];
116
+
117
+ export function groupOf(path: string): string {
118
+ return path.split(" ")[0]!;
119
+ }
120
+
121
+ /** A row's implementation: its own, else its nearest ancestor's. */
122
+ export function implOf(path: string, reg: Readonly<Record<string, Row>> = REGISTRY): Impl {
123
+ const words = path.split(" ");
124
+ for (let n = words.length; n > 0; n--) {
125
+ const impl = reg[words.slice(0, n).join(" ")]?.impl;
126
+ if (impl !== undefined) return impl;
127
+ }
128
+ throw new Error(`no implementation for '${path}'`);
129
+ }
130
+
131
+ /** The direct subs of a row, in key order. */
132
+ export function subsOf(path: string, reg: Readonly<Record<string, Row>> = REGISTRY): string[] {
133
+ const depth = path.split(" ").length + 1;
134
+ return Object.keys(reg)
135
+ .filter((k) => k.startsWith(`${path} `) && k.split(" ").length === depth)
136
+ .map((k) => k.split(" ")[depth - 1]!);
137
+ }
138
+
139
+ /** The group row and every row under it. */
140
+ export function rowsOfGroup(group: string, reg: Readonly<Record<string, Row>> = REGISTRY): string[] {
141
+ return Object.keys(reg).filter((k) => k === group || k.startsWith(`${group} `));
142
+ }
143
+
144
+ export function flagsOf(path: string, reg: Readonly<Record<string, Row>> = REGISTRY): readonly Flag[] {
145
+ return reg[path]?.flags ?? [];
146
+ }
147
+
148
+ /**
149
+ * The router's table. A row per group plus the argv shapes; a `"group sub"`
150
+ * row only where it changes the answer or names a TS sub: the router reads at
151
+ * most two words, so a nested row never reaches it.
152
+ */
153
+ export function deriveTable(reg: Readonly<Record<string, Row>> = REGISTRY): Record<string, Impl> {
154
+ const table: Record<string, Impl> = { ...ARGV_SHAPES };
155
+ for (const path of Object.keys(reg)) {
156
+ const words = path.split(" ");
157
+ if (words.length === 1) table[path] = implOf(path, reg);
158
+ else if (words.length === 2) {
159
+ const impl = implOf(path, reg);
160
+ if (impl === "ts" || impl !== implOf(words[0]!, reg)) table[path] = impl;
161
+ }
162
+ }
163
+ return table;
164
+ }
165
+
166
+ /** The `--output json` allow-list: a group maps to `true` (every sub) or to
167
+ * the subs that qualify. */
168
+ export function deriveJsonSurfaces(reg: Readonly<Record<string, Row>> = REGISTRY): Record<string, true | readonly string[]> {
169
+ const out: Record<string, true | string[]> = Object.fromEntries(ARGV_SHAPE_JSON.map((shape) => [shape, true as const]));
170
+ for (const [path, row] of Object.entries(reg)) {
171
+ if (row.json !== true) continue;
172
+ const words = path.split(" ");
173
+ if (words.length === 1) out[path] = true;
174
+ else if (words.length === 2) {
175
+ const cur = out[words[0]!];
176
+ if (cur !== true) out[words[0]!] = [...(cur ?? []), words[1]!];
177
+ }
178
+ }
179
+ return out;
180
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * router.ts — which implementation runs a command line.
3
+ *
4
+ * `TABLE` is derived from src/cli/registry.ts: a row for every group, the
5
+ * three argv shapes that are not verbs (empty argv, `-h`, `--help`), and a
6
+ * `"group sub"` row where a sub's implementation differs from its group's.
7
+ *
8
+ * A `ts` row runs in this process. A key is a group (`"completion"`)
9
+ * or a group and its first argument (`"completion bash"`); the longer key wins. An
10
+ * argv whose verb is not in the table is `unknown`: the router owns the
11
+ * "Unknown command" refusal (src/main.ts).
12
+ */
13
+
14
+ import { locateVerb } from "./args";
15
+ import { deriveTable, type Impl } from "./registry";
16
+
17
+ export type { Impl };
18
+
19
+ export const TABLE: Readonly<Record<string, Impl>> = deriveTable();
20
+
21
+ export type Route = { kind: "ts"; verb: string } | { kind: "unknown"; verb: string };
22
+
23
+ /** Resolve one command line. */
24
+ export function route(argv: readonly string[], table: Readonly<Record<string, Impl>> = TABLE): Route {
25
+ const [verb = "", sub] = locateVerb(argv);
26
+ const long = sub !== undefined ? `${verb} ${sub}` : undefined;
27
+ const hit =
28
+ long !== undefined && Object.hasOwn(table, long) ? table[long] : Object.hasOwn(table, verb) ? table[verb] : undefined;
29
+ if (hit === "ts") return { kind: hit, verb };
30
+ return { kind: "unknown", verb };
31
+ }