@sibylline-labs/eris 0.0.0-stage → 0.3.1

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
package/src/build.ts ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * build.ts — compile an eris distribution into one executable with `Bun.build`:
3
+ * this repository's (scripts/build.ts), or another repository's that depends on
4
+ * this package and calls `build` from `eris/sdk` (docs/dev/PLUGINS.md#distributions).
5
+ * It builds for the host unless given a `target`.
6
+ *
7
+ * What the binary carries:
8
+ *
9
+ * - the program: the distribution's entry and everything it imports, this
10
+ * package's core included, with every third-party package bundled, except a
11
+ * specifier a `stubs` entry matches, which bundles as a module that throws if
12
+ * evaluated. A stub is how a binary leaves out a module it must not carry (a
13
+ * native library would be extracted to a temp dir and loaded from there).
14
+ * Marking it `external` does not work: a single-file compile hoists an
15
+ * external's static import to the top of the bundle, so every verb would fail
16
+ * at startup without a `node_modules`;
17
+ * - the files eris reads at runtime, as the virtual module `eris:assets`
18
+ * (relative path → text): every file under each `assetRoots` entry, and every
19
+ * `docs/*.md` the bundled source names (a `docs:` line or a `fix:` text), taken
20
+ * from the first of `docRoots` that has it. `src/runtime/home.ts` writes them
21
+ * to a per-build cache home;
22
+ * - `__ERIS_BUILD_ID__`: a content hash of those assets, the release repository
23
+ * and the source (the distribution's and this package's), prefixed by `tag`
24
+ * when given, so a rebuild with any change gets a new home;
25
+ * - `__ERIS_RELEASE_REPO__`: where the build's own releases are published
26
+ * (`releaseRepo()`, src/runtime/home.ts), so one distribution's binary never
27
+ * takes another's release for its own;
28
+ * - `__ERIS_VERSION__`: the `tag`, or `dev` for an untagged build (`VERSION`), which
29
+ * `eris --version` prints and `eris update` compares with the release it finds;
30
+ * - `__ERIS_VERSION_EXTRA__`: the one line a distribution adds to `eris --version`
31
+ * (`versionExtra`: the core it was built on, say), or null;
32
+ * - `__ERIS_DOCS_BASE__`: the URL a failure block's second `docs:` line appends the
33
+ * doc path to (`docsBase`), so a distribution links its own docs.
34
+ *
35
+ * The last three are the distribution's identity (`DistributionIdentity`), checked
36
+ * by the rule `runProgram({ identity })` applies to a run from source
37
+ * (`resolveIdentity`, src/runtime/home.ts). In the binary the baked values win
38
+ * over whatever its entry hands `runProgram`.
39
+ *
40
+ * The plugin list is composed here first (src/cli/compose.ts), so a refused plugin
41
+ * fails the build, not the binary's first command.
42
+ *
43
+ * The executable never autoloads a `.env` or `bunfig.toml` from the caller's cwd
44
+ * (both default ON for a bun executable): a service dir's generated `.env` would
45
+ * land in the environment, and a `bunfig.toml` preload would run as eris code.
46
+ * The `eris` shim passes `--no-env-file --config=/dev/null` for the same reason.
47
+ *
48
+ * Build time only: `eris/sdk` loads this module on the first `build()` call, so
49
+ * a binary carries it but never evaluates it.
50
+ */
51
+
52
+ import type { BunPlugin } from "bun";
53
+ import { createHash } from "node:crypto";
54
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
55
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
56
+ import { composeCli } from "./cli/compose";
57
+ import type { ErisPlugin } from "./cli/plugin";
58
+ import { REGISTRY } from "./cli/registry";
59
+ import { checkoutRoot, type DistributionIdentity, firstDocRoot, resolveIdentity } from "./runtime/home";
60
+
61
+ /** This package's root: core's source, and the docs core names. */
62
+ const CORE = checkoutRoot();
63
+
64
+ /** The source a distribution is built from, when it names none. */
65
+ const SOURCE_ROOTS = ["src", "ts", "plugins", "packages"];
66
+ const SOURCE_FILES = ["package.json", "bun.lock", "bun.lockb"];
67
+
68
+ /** A doc the source names: `docs/ERRORS.md#slug`, `docs/YAK.md`, ... */
69
+ const DOC_REF = /docs\/[A-Za-z0-9_./-]+\.md/g;
70
+
71
+ const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
72
+
73
+ export interface BuildStub {
74
+ /** The specifiers, as the source writes them, that bundle as the stub. */
75
+ readonly match: RegExp;
76
+ /** Said in the stub's error: `<specifier> is not bundled into the compiled eris binary (<reason>)`. */
77
+ readonly reason: string;
78
+ }
79
+
80
+ /** The identity fields (`releaseRepo`, `versionExtra`, `docsBase`) are the
81
+ * distribution's, each this repository's when absent (`DistributionIdentity`). */
82
+ export interface BuildOptions extends DistributionIdentity {
83
+ /** The module that composes the distribution's CLI and calls `runProgram`. */
84
+ readonly entry: string;
85
+ /** The distribution's root; the paths below are relative to it. Default: the
86
+ * nearest directory at or above the entry's that holds a package.json. */
87
+ readonly root?: string;
88
+ /** The plugins the entry composes after core's rows, composed here before bundling. */
89
+ readonly plugins?: readonly ErisPlugin[];
90
+ /** Files, and directories (every file under them), the binary carries as assets. */
91
+ readonly assetRoots?: readonly string[];
92
+ /** Where each doc the source names is looked for, in order. Default: the root, then this package. */
93
+ readonly docRoots?: readonly string[];
94
+ /** Directories (their .ts/.tsx files) and files hashed into the build id and
95
+ * scanned for docs. Default: src, ts and plugins where present, the entry,
96
+ * package.json and the lockfile. This package's own source is always added. */
97
+ readonly sourceRoots?: readonly string[];
98
+ readonly stubs?: readonly BuildStub[];
99
+ /** Default: `<root>/dist/eris-<os>-<arch>`, for the target. */
100
+ readonly outfile?: string;
101
+ /** A bun compile target (`bun-linux-x64`); the host's when absent. */
102
+ readonly target?: Bun.Build.CompileTarget;
103
+ /** A release tag: prefixed to the build id and reported by `eris --version`
104
+ * (an untagged build reports `dev`). */
105
+ readonly tag?: string;
106
+ }
107
+
108
+ export interface BuildResult {
109
+ outfile: string;
110
+ buildId: string;
111
+ assets: number;
112
+ releaseRepo: string;
113
+ }
114
+
115
+ const isSource = (rel: string) => /\.tsx?$/.test(rel);
116
+
117
+ /** Every file under `base/dir` (base-relative), sorted; dot entries and node_modules skipped. */
118
+ function walk(base: string, dir: string, keep: (rel: string) => boolean = () => true): string[] {
119
+ return readdirSync(join(base, dir))
120
+ .filter((e) => !e.startsWith(".") && e !== "node_modules")
121
+ .flatMap((e) => {
122
+ const rel = join(dir, e);
123
+ return statSync(join(base, rel)).isDirectory() ? walk(base, rel, keep) : keep(rel) ? [rel] : [];
124
+ })
125
+ .sort();
126
+ }
127
+
128
+ /** A root-relative path that stays inside the root. */
129
+ function inside(root: string, rel: string, what: string): string {
130
+ if (rel === "" || isAbsolute(rel) || rel.split(/[\\/]/).includes("..")) throw new Error(`${what} '${rel}' is not a path inside the root`);
131
+ const abs = join(root, rel);
132
+ if (!existsSync(abs)) throw new Error(`${what} '${rel}' does not exist under ${root}`);
133
+ return abs;
134
+ }
135
+
136
+ /** `rel`'s files: itself, or the files under it (`keep`-filtered). */
137
+ function filesOf(root: string, rel: string, what: string, keep?: (rel: string) => boolean): string[] {
138
+ return statSync(inside(root, rel, what)).isDirectory() ? walk(root, rel, keep) : [rel];
139
+ }
140
+
141
+ function nearestPackageRoot(from: string): string {
142
+ for (let dir = from; ; dir = dirname(dir)) {
143
+ if (existsSync(join(dir, "package.json"))) return dir;
144
+ if (dirname(dir) === dir) throw new Error(`no package.json at or above ${from}: pass root`);
145
+ }
146
+ }
147
+
148
+ /** The source the build hashes and scans: the distribution's, then this package's, each file once. */
149
+ function sourcesOf(root: string, entry: string, given: readonly string[] | undefined): { key: string; abs: string }[] {
150
+ const own = given ?? [...SOURCE_ROOTS.filter((d) => existsSync(join(root, d))), relative(root, entry), ...SOURCE_FILES.filter((f) => existsSync(join(root, f)))];
151
+ const out = new Map<string, string>();
152
+ for (const rel of own) for (const f of filesOf(root, rel, "source root", isSource)) out.set(join(root, f), `source:${f}`);
153
+ for (const rel of [...SOURCE_ROOTS, "package.json"].filter((d) => existsSync(join(CORE, d)))) {
154
+ for (const f of filesOf(CORE, rel, "core source", isSource)) if (!out.has(join(CORE, f))) out.set(join(CORE, f), `core:${f}`);
155
+ }
156
+ return [...out].map(([abs, key]) => ({ key, abs }));
157
+ }
158
+
159
+ /** Each `docs/*.md` the source names, from the first doc root that has it. */
160
+ function namedDocs(sources: readonly { abs: string }[], docRoots: readonly string[]): Record<string, string> {
161
+ const named = new Set<string>();
162
+ for (const s of sources) if (isSource(s.abs)) for (const m of readFileSync(s.abs, "utf8").matchAll(DOC_REF)) named.add(m[0]);
163
+ const docs: Record<string, string> = {};
164
+ const missing: string[] = [];
165
+ for (const doc of [...named].sort()) {
166
+ const from = firstDocRoot(docRoots, doc);
167
+ if (from === undefined) missing.push(doc);
168
+ else docs[doc] = readFileSync(join(from, doc), "utf8");
169
+ }
170
+ if (missing.length > 0) throw new Error(`the source names docs that no doc root holds: ${missing.join(", ")}`);
171
+ return docs;
172
+ }
173
+
174
+ function compilePlugin(assets: Readonly<Record<string, string>>, stubs: readonly BuildStub[]): BunPlugin {
175
+ return {
176
+ name: "eris-compile",
177
+ setup(build) {
178
+ build.onResolve({ filter: /^eris:assets$/ }, () => ({ path: "eris:assets", namespace: "eris-assets" }));
179
+ build.onLoad({ filter: /.*/, namespace: "eris-assets" }, () => ({
180
+ contents: `export const ASSETS = ${JSON.stringify(assets)};\n`,
181
+ loader: "js",
182
+ }));
183
+ stubs.forEach((stub, n) => {
184
+ const namespace = `eris-stub-${n}`;
185
+ build.onResolve({ filter: stub.match }, (args) => ({ path: args.path, namespace }));
186
+ build.onLoad({ filter: /.*/, namespace }, (args) => ({
187
+ contents: `throw new Error(${JSON.stringify(`${args.path} is not bundled into the compiled eris binary (${stub.reason})`)});\n`,
188
+ loader: "js",
189
+ }));
190
+ });
191
+ },
192
+ };
193
+ }
194
+
195
+ export async function build(opts: BuildOptions): Promise<BuildResult> {
196
+ if (opts.tag !== undefined && !TAG.test(opts.tag)) throw new Error(`--tag must match ${TAG}: ${JSON.stringify(opts.tag)}`);
197
+ const { releaseRepo, versionExtra, docsBase } = resolveIdentity(opts);
198
+ const entry = resolve(opts.entry);
199
+ if (!existsSync(entry) || !statSync(entry).isFile()) throw new Error(`entry ${entry} is not a file`);
200
+ const root = resolve(opts.root ?? nearestPackageRoot(dirname(entry)));
201
+ composeCli(REGISTRY, {}, opts.plugins ?? []);
202
+
203
+ const sources = sourcesOf(root, entry, opts.sourceRoots);
204
+ const docRoots = [...new Set((opts.docRoots ?? [root, CORE]).map((d) => resolve(root, d)))];
205
+ const assets: Record<string, string> = {};
206
+ for (const rel of opts.assetRoots ?? []) for (const f of filesOf(root, rel, "asset root")) assets[f] = readFileSync(join(root, f), "utf8");
207
+ for (const [doc, text] of Object.entries(namedDocs(sources, docRoots))) if (!Object.hasOwn(assets, doc)) assets[doc] = text;
208
+ const sorted = Object.fromEntries(Object.keys(assets).sort().map((k) => [k, assets[k]!]));
209
+
210
+ const h = createHash("sha256");
211
+ const add = (key: string, bytes: Buffer) => {
212
+ h.update(`${key}\0${bytes.length}\0`);
213
+ h.update(bytes);
214
+ };
215
+ add("release", Buffer.from(releaseRepo, "utf8"));
216
+ add("version-extra", Buffer.from(versionExtra ?? "", "utf8"));
217
+ add("docs-base", Buffer.from(docsBase, "utf8"));
218
+ for (const [rel, text] of Object.entries(sorted)) add(`asset:${rel}`, Buffer.from(text, "utf8"));
219
+ for (const s of sources) add(s.key, readFileSync(s.abs));
220
+ const hash = h.digest("hex").slice(0, 16);
221
+ const buildId = opts.tag ? `${opts.tag}-${hash}` : hash;
222
+
223
+ const platform = opts.target !== undefined ? opts.target.replace(/^bun-/, "") : `${process.platform}-${process.arch}`;
224
+ const outfile = resolve(root, opts.outfile ?? join("dist", `eris-${platform}`));
225
+ const result = await Bun.build({
226
+ entrypoints: [entry],
227
+ compile: {
228
+ outfile,
229
+ ...(opts.target !== undefined ? { target: opts.target } : {}),
230
+ autoloadDotenv: false,
231
+ autoloadBunfig: false,
232
+ autoloadTsconfig: false,
233
+ autoloadPackageJson: false,
234
+ },
235
+ define: {
236
+ __ERIS_BUILD_ID__: JSON.stringify(buildId),
237
+ __ERIS_RELEASE_REPO__: JSON.stringify(releaseRepo),
238
+ __ERIS_VERSION__: JSON.stringify(opts.tag ?? "dev"),
239
+ __ERIS_VERSION_EXTRA__: JSON.stringify(versionExtra),
240
+ __ERIS_DOCS_BASE__: JSON.stringify(docsBase),
241
+ },
242
+ plugins: [compilePlugin(sorted, opts.stubs ?? [])],
243
+ });
244
+ if (!result.success) {
245
+ throw new Error(`bun build failed:\n${result.logs.map((l) => String(l)).join("\n")}`);
246
+ }
247
+ return { outfile, buildId, assets: Object.keys(sorted).length, releaseRepo };
248
+ }
@@ -0,0 +1,434 @@
1
+ /**
2
+ * args.ts — the one way a TS verb reads its argv: from its row in
3
+ * src/cli/registry.ts. A verb names its row; the parser accepts exactly the
4
+ * row's flags and operands, and the handler's argument type is derived from
5
+ * the row's literal declaration, so a flag the row does not declare can be
6
+ * neither passed nor read.
7
+ *
8
+ * The grammar, the same for every verb:
9
+ *
10
+ * - `-h` / `--help` anywhere before `--` asks for help; it wins over any refusal.
11
+ * - `--flag` (a boolean), `--flag value` or `--flag=value` (a value flag). A
12
+ * value that looks like a flag (`--issue --yes`) is refused: pass it joined
13
+ * (`--issue=--yes`) if it really is one. A boolean given `=x` is refused.
14
+ * - A repeatable value flag collects its values in order; any other value
15
+ * flag given twice is refused rather than one value silently dropped.
16
+ * - A `deferredValue` flag (a `--from <file>`) takes the next operand
17
+ * instead: a declared flag may sit between, and a dash-led word directly
18
+ * after it is its value.
19
+ * - `-y`-style aliases come from the row (`Flag.alias`).
20
+ * - `--` ends the flags: every later word is an operand, dash or not.
21
+ * - `-` alone and a negative number (`-5`) are operands.
22
+ * - Operands fill the row's `pos` in order; a word past the last is refused.
23
+ * A missing operand is not: the verb names what is missing in its own words.
24
+ *
25
+ * Every refusal carries the row's usage line. The ast-grep rule
26
+ * `adhoc-argv-parse` (rules/) keeps flag parsing in this file. Ahead of the
27
+ * verb's parse, every command line passes the global `--output` pre-pass
28
+ * (`parseGlobal`, below).
29
+ */
30
+
31
+ import { renderFailureBlock } from "../../ts/lib/eris-fail";
32
+ import { type OutputFormat, sanitizeDisplay } from "./output";
33
+ import { type Flag, type FlagName, REGISTRY, type Row, subsOf, VERBS, type VerbPath } from "./registry";
34
+
35
+ /** A verb stops early by throwing this after writing its diagnostic (re-exported
36
+ * by src/cli/ts-verb.ts, where the router catches it). */
37
+ export class CliExit extends Error {
38
+ constructor(readonly code: number) {
39
+ super(`exit ${code}`);
40
+ }
41
+ }
42
+
43
+ // --- the global `--output text|json` pre-pass ----------------------------------------------
44
+
45
+ export class GlobalFlagError extends Error {
46
+ constructor(readonly block: string) {
47
+ super(block);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Where the argv's verb and sub-verb sit once the global flag is stripped —
53
+ * the same walk as `output::parse_global`, but it never fails: a malformed
54
+ * `--output` is reported by whichever implementation the verb routes to.
55
+ */
56
+ export function locateVerb(argv: readonly string[]): string[] {
57
+ const out: string[] = [];
58
+ let terminated = false;
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const a = argv[i]!;
61
+ if (terminated) out.push(a);
62
+ else if (a === "--") terminated = true;
63
+ else if (a === "--output") i++;
64
+ else if (!a.startsWith("--output=")) out.push(a);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function formatError(got: string, home: string): GlobalFlagError {
70
+ const what = `--output takes 'text' or 'json'${got !== "" ? ` (got '${sanitizeDisplay(got)}')` : ""}`;
71
+ return new GlobalFlagError(
72
+ renderFailureBlock(
73
+ {
74
+ slug: "output-format-unknown",
75
+ what,
76
+ fix:
77
+ "eris --output json <command> machine rendering (JSON on stdout)\n" +
78
+ "eris --output text <command> human rendering (the default)",
79
+ docs: "docs/OUTPUT-CONTRACT.md#2-the-flag",
80
+ },
81
+ home,
82
+ ),
83
+ );
84
+ }
85
+
86
+ /**
87
+ * `output::parse_global`. Throws `GlobalFlagError` carrying the exact block.
88
+ *
89
+ * `--` ends the global flags. Bash dropped it; a TS verb asks to keep it
90
+ * (`keepTerminator`) so its own parse still sees where the flags end. A `--`
91
+ * ahead of the verb is dropped either way.
92
+ */
93
+ export function parseGlobal(
94
+ argv: readonly string[],
95
+ home: string,
96
+ opts: { keepTerminator?: boolean } = {},
97
+ ): { format: OutputFormat; argv: string[] } {
98
+ const rest: string[] = [];
99
+ let fmt = "";
100
+ let terminated = false;
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const a = argv[i]!;
103
+ if (terminated) {
104
+ rest.push(a);
105
+ continue;
106
+ }
107
+ if (a === "--") {
108
+ terminated = true;
109
+ if (opts.keepTerminator === true && rest.length > 0) rest.push(a);
110
+ } else if (a === "--output") {
111
+ const v = argv[i + 1] ?? "";
112
+ if (v === "") throw formatError("", home);
113
+ fmt = v;
114
+ i++;
115
+ } else if (a.startsWith("--output=")) fmt = a.slice("--output=".length);
116
+ else rest.push(a);
117
+ }
118
+ if (fmt === "") return { format: "text", argv: rest };
119
+ if (fmt === "text" || fmt === "json") return { format: fmt, argv: rest };
120
+ throw formatError(fmt, home);
121
+ }
122
+
123
+ // --- types derived from the declaration ----------------------------------------------
124
+
125
+ type RowOf<P extends VerbPath> = (typeof VERBS)[P];
126
+ type FlagsOfRow<R> = [R] extends [{ readonly flags: readonly (infer F)[] }] ? F : never;
127
+ type ValueOf<F> = F extends { readonly repeatable: true } ? string[] : F extends { readonly takesValue: true } ? string | undefined : boolean;
128
+
129
+ /** A row's flags, keyed by the declared name: a boolean, a value (absent:
130
+ * `undefined`), or a repeatable flag's values (absent: `[]`). Derived from the
131
+ * row's literal type, a core verb's or a plugin's (src/cli/plugin.ts). */
132
+ export type RowFlagValues<R> = { readonly [F in FlagsOfRow<R> as F extends { readonly name: infer N extends FlagName } ? N : never]: ValueOf<F> };
133
+
134
+ /** A core verb's flags. */
135
+ export type FlagValues<P extends VerbPath> = RowFlagValues<RowOf<P>>;
136
+
137
+ export interface RowArgs<R> {
138
+ readonly flags: RowFlagValues<R>;
139
+ /** The operands, in order, at most as many as the row declares. */
140
+ readonly pos: readonly string[];
141
+ }
142
+
143
+ export type VerbArgs<P extends VerbPath> = RowArgs<RowOf<P>>;
144
+
145
+ /** The direct subs of a group, from the registry. */
146
+ export type SubOf<G extends VerbPath> = {
147
+ [K in VerbPath]: K extends `${G} ${infer S}` ? (S extends `${string} ${string}` ? never : S) : never;
148
+ }[VerbPath];
149
+
150
+ export type ArgError =
151
+ | { readonly kind: "unknown-flag"; readonly token: string }
152
+ | { readonly kind: "missing-value"; readonly flag: string; readonly next: string | undefined; readonly inline: boolean }
153
+ | { readonly kind: "unexpected-value"; readonly flag: string }
154
+ | { readonly kind: "repeated-flag"; readonly flag: string }
155
+ | { readonly kind: "unexpected-arg"; readonly token: string };
156
+
157
+ export type Parsed<P extends VerbPath> = { readonly kind: "help" } | ({ readonly kind: "ok" } & VerbArgs<P>) | { readonly kind: "error"; readonly error: ArgError };
158
+
159
+ // --- the parser ------------------------------------------------------------------------
160
+
161
+ const isHelpToken = (a: string): boolean => a === "-h" || a === "--help";
162
+
163
+ /** Whether a help token appears before any `--` terminator, anywhere in the argv. */
164
+ export function asksForHelp(argv: readonly string[]): boolean {
165
+ for (const a of argv) {
166
+ if (a === "--") return false;
167
+ if (isHelpToken(a)) return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /** A word the parser reads as an operand although it starts with `-`. */
173
+ const isDashOperand = (a: string): boolean => a === "-" || /^-[0-9]/.test(a);
174
+
175
+ /** A token in a value slot that is really the next flag. */
176
+ const looksLikeFlag = (a: string): boolean => /^--?[A-Za-z]/.test(a) || a === "--";
177
+
178
+ function rowOf(path: string, reg: Readonly<Record<string, Row>> = REGISTRY): Row {
179
+ const row = Object.hasOwn(reg, path) ? reg[path] : undefined;
180
+ if (row === undefined) throw new Error(`no registry row for '${path}'`);
181
+ return row;
182
+ }
183
+
184
+ /** How many operands a row takes: `Infinity` once one is `name...`. */
185
+ function maxOperands(row: Row): number {
186
+ const pos = row.pos ?? [];
187
+ return pos.some((p) => p.endsWith("...")) ? Number.POSITIVE_INFINITY : pos.length;
188
+ }
189
+
190
+ /** Parse `argv` (everything after the verb's path) against `path`'s row. Pure. */
191
+ export function parseVerbArgs<P extends VerbPath>(path: P, argv: readonly string[]): Parsed<P> {
192
+ return parseRow(rowOf(path), argv) as Parsed<P>;
193
+ }
194
+
195
+ /** The row-driven parse, for a row given directly (tests, and `parseVerbArgs`). */
196
+ export function parseRow(row: Row, argv: readonly string[]): Parsed<VerbPath> {
197
+ const end = argv.indexOf("--");
198
+ const flagged = end === -1 ? argv : argv.slice(0, end);
199
+ if (flagged.some(isHelpToken)) return { kind: "help" };
200
+
201
+ const byName = new Map<string, Flag>();
202
+ for (const f of row.flags ?? []) {
203
+ byName.set(f.name, f);
204
+ if (f.alias !== undefined) byName.set(f.alias, f);
205
+ }
206
+ const values: Record<string, boolean | string | string[] | undefined> = {};
207
+ for (const f of row.flags ?? []) values[f.name] = f.repeatable === true ? [] : f.takesValue ? undefined : false;
208
+ const seen = new Set<string>();
209
+ const pos: string[] = [];
210
+ const max = maxOperands(row);
211
+ const fail = (error: ArgError): Parsed<VerbPath> => ({ kind: "error", error });
212
+ const operand = (a: string): Parsed<VerbPath> | null => {
213
+ if (pos.length >= max) return fail({ kind: "unexpected-arg", token: a });
214
+ pos.push(a);
215
+ return null;
216
+ };
217
+
218
+ // A `deferredValue` flag waiting for its value, and whether the word now
219
+ // being read sits directly after it.
220
+ let pending: Flag | null = null;
221
+ let fresh = false;
222
+ const setValue = (flag: Flag, value: string): Parsed<VerbPath> | null => {
223
+ if (flag.repeatable === true) (values[flag.name] as string[]).push(value);
224
+ else if (seen.has(flag.name)) return fail({ kind: "repeated-flag", flag: flag.name });
225
+ else values[flag.name] = value;
226
+ seen.add(flag.name);
227
+ return null;
228
+ };
229
+
230
+ for (let i = 0; i < flagged.length; i++) {
231
+ const a = flagged[i]!;
232
+ const direct = fresh;
233
+ fresh = false;
234
+ const eq = a.startsWith("--") ? a.indexOf("=") : -1;
235
+ const name = eq === -1 ? a : a.slice(0, eq);
236
+ const flag = a.startsWith("-") ? byName.get(name) : undefined;
237
+ if (pending !== null && a !== "" && flag === undefined && (direct || !a.startsWith("-") || isDashOperand(a))) {
238
+ const refused = setValue(pending, a);
239
+ if (refused !== null) return refused;
240
+ pending = null;
241
+ continue;
242
+ }
243
+ if (!a.startsWith("-") || isDashOperand(a)) {
244
+ const refused = operand(a);
245
+ if (refused !== null) return refused;
246
+ continue;
247
+ }
248
+ if (flag === undefined) return fail({ kind: "unknown-flag", token: a });
249
+ if (!flag.takesValue) {
250
+ if (eq !== -1) return fail({ kind: "unexpected-value", flag: flag.name });
251
+ values[flag.name] = true;
252
+ continue;
253
+ }
254
+ if (flag.deferredValue === true && eq === -1) {
255
+ if (pending !== null) return fail({ kind: "missing-value", flag: pending.name, next: a, inline: false });
256
+ pending = flag;
257
+ fresh = true;
258
+ continue;
259
+ }
260
+ let value: string;
261
+ if (eq !== -1) {
262
+ value = a.slice(eq + 1);
263
+ if (value === "") return fail({ kind: "missing-value", flag: flag.name, next: undefined, inline: true });
264
+ } else {
265
+ const next = flagged[i + 1];
266
+ if (next === undefined || next === "" || looksLikeFlag(next)) {
267
+ return fail({ kind: "missing-value", flag: flag.name, next: end !== -1 && i + 1 === flagged.length ? "--" : next, inline: false });
268
+ }
269
+ value = next;
270
+ i++;
271
+ }
272
+ const refused = setValue(flag, value);
273
+ if (refused !== null) return refused;
274
+ }
275
+ if (pending !== null) return fail({ kind: "missing-value", flag: pending.name, next: end !== -1 ? "--" : undefined, inline: false });
276
+ if (end !== -1) {
277
+ for (const a of argv.slice(end + 1)) {
278
+ const refused = operand(a);
279
+ if (refused !== null) return refused;
280
+ }
281
+ }
282
+ return { kind: "ok", flags: values as FlagValues<VerbPath>, pos };
283
+ }
284
+
285
+ // --- the words of a refusal and of help --------------------------------------------------
286
+
287
+ /** A value echoed into a diagnostic, on one line: C0 controls and DEL removed,
288
+ * a newline shown as `\n` (a failure block is parsed line by line). */
289
+ const shown = (s: string): string => s.replace(/\n/g, "\\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
290
+
291
+ function operandWords(row: Row): string {
292
+ return (row.pos ?? [])
293
+ .map((p) => (p.endsWith("...") ? `[${p.slice(0, -3)}...]` : p.endsWith("?") ? `[${p.slice(0, -1)}]` : `<${p}>`))
294
+ .join(" ");
295
+ }
296
+
297
+ function flagWords(row: Row): string {
298
+ return (row.flags ?? []).map((f) => `[${f.name}${f.takesValue ? " <value>" : ""}]${f.repeatable === true ? "..." : ""}`).join(" ");
299
+ }
300
+
301
+ /** `eris <path> <usage>`: the row's own line, else one generated from it. */
302
+ export function usageLine(path: string, row: Row = rowOf(path)): string {
303
+ const tail = row.usage ?? [operandWords(row), flagWords(row)].filter((s) => s !== "").join(" ");
304
+ return `eris ${path}${tail !== "" ? ` ${tail}` : ""}`;
305
+ }
306
+
307
+ /** The refusal's one-line `what`, lower-case, without the usage. */
308
+ export function argErrorWhat(e: ArgError): string {
309
+ switch (e.kind) {
310
+ case "unknown-flag":
311
+ return `unknown flag '${shown(e.token)}'`;
312
+ case "unexpected-arg":
313
+ return `unexpected argument '${shown(e.token)}'`;
314
+ case "unexpected-value":
315
+ return `${e.flag} takes no value`;
316
+ case "repeated-flag":
317
+ return `${e.flag} was given more than once — one value is kept, so the other would be dropped`;
318
+ case "missing-value":
319
+ return `${e.flag} requires a value — ${
320
+ e.next === undefined || e.next === "" || e.next === "--"
321
+ ? "no value follows it"
322
+ : `the next argument is '${shown(e.next)}', which is itself a flag — not a value`
323
+ }`;
324
+ }
325
+ }
326
+
327
+ /** The refusal as eris prints it: `ERROR <line>` for most, the documented
328
+ * `flag-value-missing` block for a value flag with no value. */
329
+ export function argErrorText(path: string, e: ArgError, home: string, row: Row = rowOf(path)): string {
330
+ const usage = usageLine(path, row);
331
+ switch (e.kind) {
332
+ case "missing-value":
333
+ return renderFailureBlock(
334
+ {
335
+ slug: "flag-value-missing",
336
+ what: argErrorWhat(e),
337
+ fix: `pass the value immediately after the flag:\n ${e.flag} <value>\nor in the joined form:\n ${e.flag}=<value>`,
338
+ docs: "docs/ERRORS.md#flag-value-missing",
339
+ },
340
+ home,
341
+ );
342
+ case "unknown-flag":
343
+ return `ERROR Unknown flag '${shown(e.token)}' (usage: ${usage})\n`;
344
+ case "unexpected-arg": {
345
+ const hint = row.extraHint;
346
+ return `ERROR Unexpected arg '${shown(e.token)}'${hint !== undefined ? ` — ${hint}` : ""} (usage: ${usage})\n`;
347
+ }
348
+ case "unexpected-value":
349
+ case "repeated-flag":
350
+ return `ERROR ${argErrorWhat(e)} (usage: ${usage})\n`;
351
+ }
352
+ }
353
+
354
+ /** The help a row generates: its usage line, summary, and flags. */
355
+ export function helpText(path: string, row: Row = rowOf(path)): string {
356
+ const flags = (row.flags ?? []).map(
357
+ (f) => ` ${f.name}${f.takesValue ? " <value>" : ""}${f.alias !== undefined ? `, ${f.alias}` : ""}${f.repeatable === true ? " (repeatable)" : ""}\n`,
358
+ );
359
+ return `Usage: ${usageLine(path, row)}\n\n${row.summary}\n\nFlags:\n${flags.join("")} --help, -h Print this usage text\n`;
360
+ }
361
+
362
+ // --- the verb-facing entry -----------------------------------------------------------------
363
+
364
+ export interface ArgSinks {
365
+ readonly erisDir: string;
366
+ readonly stdout: { write(chunk: string): unknown };
367
+ readonly stderr: { write(chunk: string): unknown };
368
+ }
369
+
370
+ export interface ReadOptions {
371
+ /** The verb's own help text; the row's generated help when absent. */
372
+ readonly help?: string;
373
+ /** A verb whose refusal is a documented, slugged block renders the kinds it
374
+ * owns here and stops; returning falls through to `argErrorText`. */
375
+ readonly refuse?: (e: ArgError, what: string) => void;
376
+ }
377
+
378
+ /** Write `e`'s uniform refusal and stop with exit 1. A row core's registry
379
+ * does not hold (a plugin's) is passed with its path. */
380
+ export function refuseArg(path: VerbPath, e: ArgError, io: ArgSinks): never;
381
+ export function refuseArg(path: string, e: ArgError, io: ArgSinks, row: Row): never;
382
+ export function refuseArg(path: string, e: ArgError, io: ArgSinks, row: Row = rowOf(path)): never {
383
+ io.stderr.write(argErrorText(path, e, io.erisDir, row));
384
+ throw new CliExit(1);
385
+ }
386
+
387
+ /**
388
+ * Parse for a verb handler. Help: written to stdout, `null` returned (the verb
389
+ * returns 0). A refusal: written to stderr, then `CliExit(1)`.
390
+ */
391
+ export function readVerbArgs<P extends VerbPath>(path: P, argv: readonly string[], io: ArgSinks, opts: ReadOptions = {}): VerbArgs<P> | null {
392
+ return readRowArgs(path, rowOf(path), argv, io, opts) as VerbArgs<P> | null;
393
+ }
394
+
395
+ /** `readVerbArgs` for a row given directly: a plugin's (src/cli/plugin.ts). */
396
+ export function readRowArgs<R extends Row>(path: string, row: R, argv: readonly string[], io: ArgSinks, opts: ReadOptions = {}): RowArgs<R> | null {
397
+ const p = parseRow(row, argv);
398
+ if (p.kind === "help") {
399
+ io.stdout.write(opts.help ?? helpText(path, row));
400
+ return null;
401
+ }
402
+ if (p.kind === "error") {
403
+ opts.refuse?.(p.error, argErrorWhat(p.error));
404
+ io.stderr.write(argErrorText(path, p.error, io.erisDir, row));
405
+ throw new CliExit(1);
406
+ }
407
+ return p as unknown as RowArgs<R>;
408
+ }
409
+
410
+ export type GroupArgs<G extends VerbPath> =
411
+ | { readonly kind: "bare" }
412
+ | { readonly kind: "help" }
413
+ | { readonly kind: "sub"; readonly sub: SubOf<G>; readonly rest: string[] }
414
+ | { readonly kind: "unknown"; readonly sub: string };
415
+
416
+ /**
417
+ * A group dispatcher's first word: none (`bare`), `-h`/`--help` (`help`), one
418
+ * of the group's subs from the registry, or anything else (`unknown`, refused
419
+ * by the dispatcher in its own words). A leading `--` is skipped: a sub name
420
+ * never starts with a dash.
421
+ */
422
+ export function groupArgs<G extends VerbPath>(group: G, argv: readonly string[]): GroupArgs<G> {
423
+ return groupArgsIn(REGISTRY, group, argv) as GroupArgs<G>;
424
+ }
425
+
426
+ /** `groupArgs` over a given set of rows: a plugin's (src/cli/plugin.ts). */
427
+ export function groupArgsIn(reg: Readonly<Record<string, Row>>, group: string, argv: readonly string[]): GroupArgs<never> | { readonly kind: "sub"; readonly sub: string; readonly rest: string[] } {
428
+ const words = argv[0] === "--" ? argv.slice(1) : argv;
429
+ const first = words[0];
430
+ if (first === undefined || first === "") return { kind: "bare" };
431
+ if (isHelpToken(first)) return { kind: "help" };
432
+ if (subsOf(group, reg).includes(first)) return { kind: "sub", sub: first, rest: words.slice(1) };
433
+ return { kind: "unknown", sub: first };
434
+ }