@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.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- 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
|
+
}
|
package/src/cli/args.ts
ADDED
|
@@ -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
|
+
}
|