@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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash.ts — `eris completion bash`: the bash completion script on stdout.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CliContext } from "../../../main";
|
|
6
|
+
import type { Session } from "../../compose";
|
|
7
|
+
import { runShell } from "./shell";
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: eris completion bash
|
|
10
|
+
|
|
11
|
+
Emit the bash completion script to stdout. Load it into the current shell
|
|
12
|
+
or make it permanent:
|
|
13
|
+
|
|
14
|
+
source <(eris completion bash) # this shell only
|
|
15
|
+
echo 'source <(eris completion bash)' >> ~/.bashrc # every shell
|
|
16
|
+
|
|
17
|
+
The emitted script embeds a snapshot of the eris command tree, generated from
|
|
18
|
+
the verb registry; sourcing via process substitution (as above) keeps it
|
|
19
|
+
current automatically. See docs/COMPLETION.md.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export function runBash(args: string[], ctx: CliContext & { session?: Session }): number {
|
|
23
|
+
return runShell("bash", USAGE, args, ctx);
|
|
24
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain.ts — the completion domains a row's slots carry (`CompletionDomain`,
|
|
3
|
+
* src/cli/registry.ts): the function a domain names, and the check composition
|
|
4
|
+
* (src/cli/compose.ts) runs on a slot's words and on every helper text before
|
|
5
|
+
* the emitter (./emit.ts) writes them into the script.
|
|
6
|
+
*
|
|
7
|
+
* The script is sourced by every shell that loads eris completion (bash, and
|
|
8
|
+
* zsh through bashcompinit), and a helper sits between core's own definitions
|
|
9
|
+
* in it. Sourcing must define the helper's one function and run nothing, so the
|
|
10
|
+
* check must find where that function ends exactly as both shells do. It does
|
|
11
|
+
* not model shell quoting: it accepts a subset whose framing bash and zsh agree
|
|
12
|
+
* on and refuses everything else, naming the line:
|
|
13
|
+
*
|
|
14
|
+
* - the text is exactly one definition, `__eris_dom_<name>() {`, whose body
|
|
15
|
+
* closes at its final `}`, then a newline: nothing before it, nothing after;
|
|
16
|
+
* - printable ASCII, tab and newline only; no comment, no backtick, no heredoc
|
|
17
|
+
* or here-string, no `$'…'`, `$"…"` or `$[…]`, no function defined inside;
|
|
18
|
+
* - a backslash only inside quotes, and only before a letter or digit;
|
|
19
|
+
* - `${ }` holds a name and its operators only (no quote, nesting or `( )`);
|
|
20
|
+
* - `{` and `}` as words only where both shells read them as a group's braces:
|
|
21
|
+
* `{` in command position, `}` after `;` or a newline, closing one the scan
|
|
22
|
+
* opened (zsh reads a lone `}` as a closing brace anywhere on a line);
|
|
23
|
+
* - inside `$( )`, no `case` and no unbalanced parenthesis inside a quote,
|
|
24
|
+
* which older bash can read as the substitution's end.
|
|
25
|
+
*
|
|
26
|
+
* A helper that is valid shell may still be refused; rewrite it into the
|
|
27
|
+
* subset. Pure and import-free, because composition runs on every command line.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { CompletionDomain, SlotWord } from "../../registry";
|
|
31
|
+
|
|
32
|
+
/** A literal slot word: the emitted script embeds it inside double quotes and a case label. */
|
|
33
|
+
export const WORD = /^[A-Za-z0-9_][A-Za-z0-9_?-]*$/;
|
|
34
|
+
|
|
35
|
+
const DOMAIN_NAME = /^@[a-z][a-z0-9_]*$/;
|
|
36
|
+
const PREFIX = "__eris_dom_";
|
|
37
|
+
/** The one header a helper starts with: `name() {`, then a blank or newline. */
|
|
38
|
+
const HEADER = /^(__eris_dom_[A-Za-z0-9_]+)\(\)[ \t]*\{(?=[ \t\n])/;
|
|
39
|
+
/** Anything but printable ASCII, tab and newline. */
|
|
40
|
+
const OUTSIDE_ASCII = /[^\t\n\x20-\x7e]/;
|
|
41
|
+
/** Characters that end a shell word. */
|
|
42
|
+
const META = new Set([" ", "\t", "\n", ";", "&", "|", "(", ")", "<", ">"]);
|
|
43
|
+
/** What `${` … `}` may hold: a name, `$name`, and the operators on it. */
|
|
44
|
+
const PARAM_CHAR = /[A-Za-z0-9_:=+?#%/*@!.,~^\-[\] \t]/;
|
|
45
|
+
/** What may follow `$` inside `${ }`: a name or a special parameter. */
|
|
46
|
+
const PARAM_DOLLAR = /[A-Za-z0-9_@*#?!$-]/;
|
|
47
|
+
|
|
48
|
+
/** The function the emitted slot calls for `domain`. */
|
|
49
|
+
export function domainFunction(domain: string): string {
|
|
50
|
+
return `${PREFIX}${domain.slice(1)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every helper text a domain carries: its library, then its own function. */
|
|
54
|
+
export function helperTexts(d: CompletionDomain): readonly string[] {
|
|
55
|
+
return [...(d.library ?? []), d.helper];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Why `word` cannot stand in a slot, else null. Its helper texts are `scanHelper`'s. */
|
|
59
|
+
export function slotWordRefusal(word: SlotWord): string | null {
|
|
60
|
+
if (typeof word === "string") {
|
|
61
|
+
return word === "@file" || WORD.test(word) ? null : `'${word}' is not a completion word (letters, digits, '_', '?', '-'), '@file' or a domain`;
|
|
62
|
+
}
|
|
63
|
+
const d = word as Partial<CompletionDomain> | null;
|
|
64
|
+
if (typeof d !== "object" || d === null || typeof d.domain !== "string" || typeof d.helper !== "string") {
|
|
65
|
+
return `${JSON.stringify(word)} is neither a completion word nor a domain ({ domain, helper })`;
|
|
66
|
+
}
|
|
67
|
+
if (d.library !== undefined && (!Array.isArray(d.library) || !d.library.every((t) => typeof t === "string"))) {
|
|
68
|
+
return `domain '${d.domain}': library must be a list of helper texts`;
|
|
69
|
+
}
|
|
70
|
+
if (d.domain === "@file") return "'@file' is built in: write it as the word '@file'";
|
|
71
|
+
if (!DOMAIN_NAME.test(d.domain)) return `domain '${d.domain}' is not a domain name ('@', then lower-case letters, digits, '_')`;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The one function a helper text defines, or why it cannot be emitted. */
|
|
76
|
+
export type HelperScan = { readonly ok: true; readonly function: string } | { readonly ok: false; readonly why: string };
|
|
77
|
+
|
|
78
|
+
const lineAt = (text: string, at: number): number => text.slice(0, at).split("\n").length;
|
|
79
|
+
|
|
80
|
+
/** Where the scan is: code (the body, or inside `$( )`), `${ }`, or a double or single quote. */
|
|
81
|
+
type Frame =
|
|
82
|
+
| { kind: "code"; readonly sub: boolean; readonly start: number; braces: number; parens: number }
|
|
83
|
+
| { kind: "param" }
|
|
84
|
+
| { kind: "dq" | "sq"; readonly inSub: boolean; parens: number };
|
|
85
|
+
|
|
86
|
+
class Refusal extends Error {}
|
|
87
|
+
|
|
88
|
+
/** The body from just after its opening `{`: the index just past its closing `}`. Throws a Refusal. */
|
|
89
|
+
function scanBody(text: string, start: number): number {
|
|
90
|
+
const stack: Frame[] = [{ kind: "code", sub: false, start, braces: 1, parens: 0 }];
|
|
91
|
+
const inSub = () => stack.some((f) => f.kind === "code" && f.sub);
|
|
92
|
+
const refuse = (j: number, why: string): never => {
|
|
93
|
+
throw new Refusal(`${why} (line ${lineAt(text, j)})`);
|
|
94
|
+
};
|
|
95
|
+
/** The last non-blank character before `j` in a code frame starting at `from`; "" at the frame's start. */
|
|
96
|
+
const prevWord = (j: number, from: number): string => {
|
|
97
|
+
let k = j - 1;
|
|
98
|
+
while (k >= from && (text[k] === " " || text[k] === "\t")) k--;
|
|
99
|
+
return k >= from ? text[k]! : "";
|
|
100
|
+
};
|
|
101
|
+
const backslash = (j: number) => {
|
|
102
|
+
if (!/[A-Za-z0-9]/.test(text[j + 1] ?? "")) refuse(j, "has a backslash before something other than a letter or digit");
|
|
103
|
+
};
|
|
104
|
+
for (let j = start; j < text.length; j++) {
|
|
105
|
+
const c = text[j]!;
|
|
106
|
+
const next = text[j + 1];
|
|
107
|
+
const top = stack[stack.length - 1]!;
|
|
108
|
+
if (c === "`") refuse(j, "uses a backtick: use $( ) instead");
|
|
109
|
+
if (top.kind === "sq") {
|
|
110
|
+
if (c === "'") {
|
|
111
|
+
if (top.parens !== 0) refuse(j, "has an unbalanced parenthesis in a quote inside $( )");
|
|
112
|
+
stack.pop();
|
|
113
|
+
} else if (c === "\\") backslash(j);
|
|
114
|
+
else if (top.inSub && (c === "(" || c === ")")) top.parens += c === "(" ? 1 : -1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (top.kind === "param") {
|
|
118
|
+
if (c === "}") stack.pop();
|
|
119
|
+
else if (c === "$" && PARAM_DOLLAR.test(next ?? "")) j++;
|
|
120
|
+
else if (!PARAM_CHAR.test(c)) refuse(j, "has a ${ } holding more than a name and its operators");
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (c === "$" && next === "$") {
|
|
124
|
+
j++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (c === "$" && (next === "'"|| next === '"' || next === "[")) refuse(j, `uses $${next}, which the shells read differently`);
|
|
128
|
+
if (c === "$" && next === "(") {
|
|
129
|
+
stack.push({ kind: "code", sub: true, start: j + 2, braces: 0, parens: 0 });
|
|
130
|
+
j++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (c === "$" && next === "{") {
|
|
134
|
+
stack.push({ kind: "param" });
|
|
135
|
+
j++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (top.kind === "dq") {
|
|
139
|
+
if (c === '"') {
|
|
140
|
+
if (top.parens !== 0) refuse(j, "has an unbalanced parenthesis in a quote inside $( )");
|
|
141
|
+
stack.pop();
|
|
142
|
+
} else if (c === "\\") backslash(j);
|
|
143
|
+
else if (top.inSub && (c === "(" || c === ")")) top.parens += c === "(" ? 1 : -1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// Code: the body, or the inside of a `$( )`.
|
|
147
|
+
const code = top as Extract<Frame, { kind: "code" }>;
|
|
148
|
+
if (c === "\\") refuse(j, "has a backslash outside quotes");
|
|
149
|
+
if (c === "'" || c === '"') {
|
|
150
|
+
stack.push({ kind: c === "'" ? "sq" : "dq", inSub: inSub(), parens: 0 });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (c === "#" && text[j - 1] !== "$") refuse(j, "has a comment or a bare '#': keep comments in the TypeScript beside it");
|
|
154
|
+
if (c === "<" && next === "<") refuse(j, "uses a heredoc or here-string");
|
|
155
|
+
const wordStart = j === code.start || META.has(text[j - 1]!);
|
|
156
|
+
const wordEnd = (k: number) => k >= text.length || META.has(text[k]!);
|
|
157
|
+
if (wordStart && /^function[ \t]/.test(text.slice(j, j + 9))) refuse(j, "defines a function inside a function");
|
|
158
|
+
if (wordStart && code.sub && /^case\b/.test(text.slice(j, j + 5))) refuse(j, "uses case inside $( )");
|
|
159
|
+
if (c === "(") {
|
|
160
|
+
if (/^\([ \t]*\)/.test(text.slice(j))) refuse(j, "defines a function inside a function");
|
|
161
|
+
code.parens++;
|
|
162
|
+
} else if (c === ")") {
|
|
163
|
+
if (code.parens > 0) code.parens--;
|
|
164
|
+
else if (code.sub) stack.pop();
|
|
165
|
+
} else if (c === "{" && wordStart && wordEnd(j + 1)) {
|
|
166
|
+
const before = prevWord(j, code.start);
|
|
167
|
+
const twoBefore = text.slice(0, j).trimEnd();
|
|
168
|
+
if (!/[ \t\n]/.test(next ?? "") || !["", "\n", ";", "&", "|", "("].includes(before) || twoBefore.endsWith(";;")) {
|
|
169
|
+
refuse(j, "has a '{' word outside command position");
|
|
170
|
+
}
|
|
171
|
+
code.braces++;
|
|
172
|
+
} else if (c === "}" && wordStart && wordEnd(j + 1)) {
|
|
173
|
+
const before = prevWord(j, code.start);
|
|
174
|
+
const twoBefore = text.slice(0, j).trimEnd();
|
|
175
|
+
if (!["\n", ";"].includes(before) || twoBefore.endsWith(";;") || !/[ \t\n;]/.test(next ?? "") || code.braces === 0) {
|
|
176
|
+
refuse(j, "has a '}' word that does not close a group after ';' or a newline");
|
|
177
|
+
}
|
|
178
|
+
code.braces--;
|
|
179
|
+
if (!code.sub && code.braces === 0) return j + 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return refuse(text.length - 1, stack.length > 1 ? "ends inside a quote, expansion or substitution" : "ends inside the function body");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The one function `text` defines, or why it cannot be emitted as a helper. */
|
|
186
|
+
export function scanHelper(text: string): HelperScan {
|
|
187
|
+
const fail = (why: string): HelperScan => ({ ok: false, why });
|
|
188
|
+
if (!text.endsWith("\n")) return fail("does not end with a newline");
|
|
189
|
+
const odd = OUTSIDE_ASCII.exec(text);
|
|
190
|
+
if (odd !== null) return fail(`carries a character outside printable ASCII (line ${lineAt(text, odd.index)})`);
|
|
191
|
+
const head = HEADER.exec(text);
|
|
192
|
+
if (head === null) {
|
|
193
|
+
return fail(`must be exactly one definition, starting at its first character with '${PREFIX}<name>() {'`);
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const end = scanBody(text, head[0].length);
|
|
197
|
+
if (end !== text.length - 1) return fail(`puts text after the function's closing brace (line ${lineAt(text, end)}): one function per helper text`);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
if (e instanceof Refusal) return fail(e.message);
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, function: head[1]! };
|
|
203
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emit.ts — the bash and zsh completion scripts.
|
|
3
|
+
*
|
|
4
|
+
* The command tree is generated from the registry it is handed: groups,
|
|
5
|
+
* subs, nested subs, each path's long flags, which flags take a value and
|
|
6
|
+
* which of those values are file names (`file` on the flag's row), and each
|
|
7
|
+
* row's positional slots (`complete`): literal words, file names, or a
|
|
8
|
+
* completion domain, whose helper text is emitted once and whose function the
|
|
9
|
+
* slot calls with the words typed in the earlier slots. A verb added to
|
|
10
|
+
* src/cli/registry.ts, or declared by a plugin, completes with no other edit.
|
|
11
|
+
* Composition has checked every slot word and helper (./domain.ts).
|
|
12
|
+
*
|
|
13
|
+
* The rest is the tab-press engine, which is shell by nature and is emitted
|
|
14
|
+
* as text. Its contract (docs/COMPLETION.md):
|
|
15
|
+
* - a tab press never runs eris and never touches the network;
|
|
16
|
+
* - bash-3.2-clean (macOS /bin/bash) and shared verbatim by the zsh route,
|
|
17
|
+
* which loads it through bashcompinit.
|
|
18
|
+
*
|
|
19
|
+
* This runs on every shell start (`source <(eris completion bash)`): it
|
|
20
|
+
* imports the registry and nothing else, so no other verb's source, Bash or
|
|
21
|
+
* TS, is on its path.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { REGISTRY, type Row } from "../../registry";
|
|
25
|
+
import { domainFunction, helperTexts, WORD } from "./domain";
|
|
26
|
+
|
|
27
|
+
export type Shell = "bash" | "zsh";
|
|
28
|
+
type Registry = Readonly<Record<string, Row>>;
|
|
29
|
+
|
|
30
|
+
const FLAG = /^--[a-z][a-z0-9-]*$/;
|
|
31
|
+
|
|
32
|
+
function safe(word: string, re: RegExp, what: string): string {
|
|
33
|
+
if (!re.test(word)) throw new Error(`completion: ${what} '${word}' is not shell-safe`);
|
|
34
|
+
return word;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
38
|
+
const uniqSorted = (xs: Iterable<string>): string[] => [...new Set(xs)].sort(byCodePoint);
|
|
39
|
+
|
|
40
|
+
/** The lookup key the engine builds for a command path: words joined by `__`. */
|
|
41
|
+
const keyOf = (path: string): string => path.split(" ").join("__");
|
|
42
|
+
|
|
43
|
+
function subsIn(reg: Registry, path: string): string[] {
|
|
44
|
+
const depth = path.split(" ").length + 1;
|
|
45
|
+
return Object.keys(reg)
|
|
46
|
+
.filter((k) => k.startsWith(`${path} `) && k.split(" ").length === depth)
|
|
47
|
+
.map((k) => safe(k.split(" ")[depth - 1]!, WORD, "verb"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function flagNames(row: Row | undefined): string[] {
|
|
51
|
+
return (row?.flags ?? []).map((f) => safe(f.name, FLAG, "flag"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function caseFn(name: string, arms: readonly [string, string][], fallback: string): string {
|
|
55
|
+
const body = arms.map(([label, action]) => ` ${label}) ${action} ;;\n`).join("");
|
|
56
|
+
return `${name}() {\n case "$1" in\n${body} *) ${fallback} ;;\n esac\n}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function predicateFn(name: string, subject: string, labels: readonly string[]): string {
|
|
60
|
+
const arm = labels.length > 0 ? ` ${labels.join("|")}) return 0 ;;\n` : "";
|
|
61
|
+
return `${name}() {\n case ${subject} in\n${arm} esac\n return 1\n}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The generated half: the tree, the domain helpers, the flags and the positional slots. */
|
|
65
|
+
function generated(reg: Registry): { tree: string; helpers: string; argv: string } {
|
|
66
|
+
const paths = Object.keys(reg);
|
|
67
|
+
const groups = uniqSorted(paths.filter((p) => !p.includes(" ")).map((g) => safe(g, WORD, "group")));
|
|
68
|
+
|
|
69
|
+
const verbArms: [string, string][] = [];
|
|
70
|
+
for (const path of [...paths].sort(byCodePoint)) {
|
|
71
|
+
const subs = subsIn(reg, path);
|
|
72
|
+
if (subs.length > 0) verbArms.push([keyOf(path), `echo "${uniqSorted(subs).join(" ")}"`]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A path offers its own flags; a group's own key, used before a verb is
|
|
76
|
+
// typed (and for a verb the registry does not know), offers the union of
|
|
77
|
+
// every row under it.
|
|
78
|
+
const flagArms: [string, string][] = [];
|
|
79
|
+
for (const path of [...paths].sort(byCodePoint)) {
|
|
80
|
+
const own = path.includes(" ") ? flagNames(reg[path]) : paths.filter((p) => p === path || p.startsWith(`${path} `)).flatMap((p) => flagNames(reg[p]));
|
|
81
|
+
flagArms.push([keyOf(path), `echo "${uniqSorted([...own, "--help"]).join(" ")}"`]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const valueFlags = uniqSorted(paths.flatMap((p) => (reg[p]!.flags ?? []).filter((f) => f.takesValue).map((f) => safe(f.name, FLAG, "flag"))));
|
|
85
|
+
// Registry order, first seen: core's rows precede a plugin's, and the emitted
|
|
86
|
+
// case arm lists them in that order.
|
|
87
|
+
const fileFlags = [...new Set(paths.flatMap((p) => (reg[p]!.flags ?? []).filter((f) => f.takesValue && f.file === true).map((f) => safe(f.name, FLAG, "flag"))))];
|
|
88
|
+
|
|
89
|
+
const slotArms: [string, string][] = [];
|
|
90
|
+
const fileSlots: string[] = [];
|
|
91
|
+
// Each distinct helper and library text once, in the order its first slot is emitted.
|
|
92
|
+
const texts = new Set<string>();
|
|
93
|
+
for (const path of [...paths].sort(byCodePoint)) {
|
|
94
|
+
reg[path]!.complete?.forEach((slot, n) => {
|
|
95
|
+
const label = `"${keyOf(path)} ${n}"`;
|
|
96
|
+
if (slot.includes("@file")) {
|
|
97
|
+
fileSlots.push(label);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const words = slot.map((t) => {
|
|
101
|
+
if (typeof t === "string") return safe(t, WORD, "slot word");
|
|
102
|
+
for (const text of helperTexts(t)) texts.add(text);
|
|
103
|
+
return `$(${domainFunction(t.domain)} "$@")`;
|
|
104
|
+
});
|
|
105
|
+
slotArms.push([label, `echo "${words.join(" ")}"`]);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const helpers = texts.size === 0 ? "" : `# Completion domains: the helpers the rows carry.\n${[...texts].join("\n")}\n`;
|
|
109
|
+
|
|
110
|
+
const tree =
|
|
111
|
+
`__eris_comp_groups() {\n echo "${groups.join(" ")}"\n}\n\n` +
|
|
112
|
+
`${caseFn("__eris_comp_verbs", verbArms, 'echo ""')}\n` +
|
|
113
|
+
`${caseFn("__eris_comp_flags", flagArms, 'echo ""')}\n`;
|
|
114
|
+
const argv =
|
|
115
|
+
"# Flags whose next word is a user-supplied value (never completed as a\n# positional), and the ones whose value is a file name.\n" +
|
|
116
|
+
`${predicateFn("__eris_comp_valflag", '"$1"', valueFlags)}\n` +
|
|
117
|
+
`${predicateFn("__eris_comp_fileflag", '"$1"', fileFlags)}\n` +
|
|
118
|
+
'# The positional slots: __eris_comp_slot <key> <n> [<word>...] prints the\n# words for slot n of the command <key>, given the words typed in the earlier\n# slots; __eris_comp_fileslot says it is a file.\n' +
|
|
119
|
+
`__eris_comp_slot() {\n local slot="$1 $2"\n shift 2\n case "$slot" in\n${slotArms.map(([l, a]) => ` ${l}) ${a} ;;\n`).join("")} esac\n return 0\n}\n\n` +
|
|
120
|
+
`${predicateFn("__eris_comp_fileslot", '"$1 $2"', fileSlots)}\n`;
|
|
121
|
+
return { tree, helpers, argv };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const BASH_HEADER = `# eris bash completion — generated by 'eris completion bash'.
|
|
125
|
+
# The command tree below is a snapshot of the eris verb registry
|
|
126
|
+
# (src/cli/registry.ts) at emission time; re-emit after upgrading eris, or
|
|
127
|
+
# source via process substitution so it regenerates at shell startup. Works on
|
|
128
|
+
# bash 3.2+ (macOS /bin/bash). See docs/COMPLETION.md.
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const ZSH_HEADER = `# eris zsh completion — generated by 'eris completion zsh'.
|
|
132
|
+
# Route: bashcompinit wrapping the shared bash completion body (the engine
|
|
133
|
+
# sets the ksharrays localoption so COMP_WORDS indexing matches bash). The
|
|
134
|
+
# command tree below is a snapshot of the eris verb registry at emission
|
|
135
|
+
# time; source via process substitution so it regenerates at shell startup.
|
|
136
|
+
# See docs/COMPLETION.md.
|
|
137
|
+
if [ -z "\${ZSH_VERSION:-}" ]; then
|
|
138
|
+
echo "eris: this completion script is for zsh — use 'eris completion bash' for bash" >&2
|
|
139
|
+
return 1 2>/dev/null || exit 1
|
|
140
|
+
fi
|
|
141
|
+
if ! command -v compdef >/dev/null 2>&1; then
|
|
142
|
+
autoload -U +X compinit && compinit
|
|
143
|
+
fi
|
|
144
|
+
autoload -U +X bashcompinit && bashcompinit
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
/** The tab-press entry point. */
|
|
148
|
+
const ENGINE = `_eris_completions() {
|
|
149
|
+
# Under zsh/bashcompinit, give this function bash's 0-based array
|
|
150
|
+
# indexing; under bash the command doesn't exist and is silenced.
|
|
151
|
+
setopt localoptions ksharrays 2>/dev/null || :
|
|
152
|
+
|
|
153
|
+
local cur prev group verb sub key words
|
|
154
|
+
local i w p np pw
|
|
155
|
+
COMPREPLY=()
|
|
156
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
157
|
+
prev=""
|
|
158
|
+
[ "$COMP_CWORD" -ge 1 ] && prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
159
|
+
|
|
160
|
+
# A value-taking flag right before the cursor: complete files for the
|
|
161
|
+
# file-valued ones, otherwise stay silent while the user types the value.
|
|
162
|
+
if __eris_comp_valflag "$prev"; then
|
|
163
|
+
__eris_comp_fileflag "$prev" && COMPREPLY=( $(compgen -f -- "$cur") )
|
|
164
|
+
return 0
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
168
|
+
COMPREPLY=( $(compgen -W "$(__eris_comp_groups) --help --version" -- "$cur") )
|
|
169
|
+
return 0
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
group="\${COMP_WORDS[1]}"
|
|
173
|
+
|
|
174
|
+
# Walk the words after the group (before the cursor): the first positional
|
|
175
|
+
# is the verb (verb-ful groups only), the next is the nested sub when the
|
|
176
|
+
# verb is itself a group, the rest are counted as arguments. Flags and the
|
|
177
|
+
# values of value-taking flags are skipped.
|
|
178
|
+
verb=""; sub=""; np=0; pw=()
|
|
179
|
+
i=2
|
|
180
|
+
while [ "$i" -lt "$COMP_CWORD" ]; do
|
|
181
|
+
w="\${COMP_WORDS[i]}"
|
|
182
|
+
p="\${COMP_WORDS[i-1]}"
|
|
183
|
+
if __eris_comp_valflag "$p"; then i=$((i + 1)); continue; fi
|
|
184
|
+
case "$w" in
|
|
185
|
+
-*) i=$((i + 1)); continue ;;
|
|
186
|
+
esac
|
|
187
|
+
if [ -z "$verb" ] && [ -n "$(__eris_comp_verbs "$group")" ]; then
|
|
188
|
+
verb="$w"
|
|
189
|
+
elif [ -n "$verb" ] && [ -z "$sub" ] && [ "$np" -eq 0 ] && [ -n "$(__eris_comp_verbs "\${group}__\${verb}")" ]; then
|
|
190
|
+
sub="$w"
|
|
191
|
+
else
|
|
192
|
+
pw[np]="$w"
|
|
193
|
+
np=$((np + 1))
|
|
194
|
+
fi
|
|
195
|
+
i=$((i + 1))
|
|
196
|
+
done
|
|
197
|
+
key="$group"
|
|
198
|
+
[ -n "$verb" ] && key="\${key}__\${verb}"
|
|
199
|
+
[ -n "$sub" ] && key="\${key}__\${sub}"
|
|
200
|
+
|
|
201
|
+
# Flags of the deepest known path; else the group's whole set.
|
|
202
|
+
case "$cur" in
|
|
203
|
+
-*)
|
|
204
|
+
words=$(__eris_comp_flags "$key")
|
|
205
|
+
[ -n "$words" ] || words=$(__eris_comp_flags "$group")
|
|
206
|
+
[ -n "$words" ] || words="--help"
|
|
207
|
+
COMPREPLY=( $(compgen -W "$words" -- "$cur") )
|
|
208
|
+
return 0
|
|
209
|
+
;;
|
|
210
|
+
esac
|
|
211
|
+
|
|
212
|
+
# Verb position of a verb-ful group.
|
|
213
|
+
if [ -z "$verb" ] && [ -n "$(__eris_comp_verbs "$group")" ]; then
|
|
214
|
+
COMPREPLY=( $(compgen -W "$(__eris_comp_verbs "$group")" -- "$cur") )
|
|
215
|
+
return 0
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# Nested sub position (\`eris <group> <verb> <TAB>\`).
|
|
219
|
+
if [ -n "$verb" ] && [ -z "$sub" ]; then
|
|
220
|
+
words=$(__eris_comp_verbs "\${group}__\${verb}")
|
|
221
|
+
if [ -n "$words" ]; then
|
|
222
|
+
COMPREPLY=( $(compgen -W "$words" -- "$cur") )
|
|
223
|
+
return 0
|
|
224
|
+
fi
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
# Positional slots with a known domain, handed the earlier slots' words.
|
|
228
|
+
# Everything else intentionally offers nothing (silent degradation).
|
|
229
|
+
if __eris_comp_fileslot "$key" "$np"; then
|
|
230
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
231
|
+
return 0
|
|
232
|
+
fi
|
|
233
|
+
words=$(__eris_comp_slot "$key" "$np" \${pw[@]+"\${pw[@]}"})
|
|
234
|
+
[ -n "$words" ] && COMPREPLY=( $(compgen -W "$words" -- "$cur") )
|
|
235
|
+
return 0
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
complete -F _eris_completions eris
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
/** The full script for `shell`, generated from `reg`. Deterministic: the
|
|
242
|
+
* same registry emits the same bytes. */
|
|
243
|
+
export function emitScript(shell: Shell, reg: Registry = REGISTRY): string {
|
|
244
|
+
const { tree, helpers, argv } = generated(reg);
|
|
245
|
+
return `${shell === "zsh" ? ZSH_HEADER : BASH_HEADER}${tree}${helpers}${argv}${ENGINE}`;
|
|
246
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell.ts — the one body behind `eris completion bash` and `eris completion
|
|
3
|
+
* zsh`: `--help` prints the verb's usage, no argument prints the script, and
|
|
4
|
+
* anything else is refused with nothing on stdout (a shell rc sources stdout).
|
|
5
|
+
*
|
|
6
|
+
* The tree is the session's composed registry (src/cli/compose.ts): core rows
|
|
7
|
+
* plus plugin rows. Rows are data, so no plugin handler loads here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CliExit, readVerbArgs } from "../../args";
|
|
11
|
+
import type { CliContext } from "../../../main";
|
|
12
|
+
import type { Session } from "../../compose";
|
|
13
|
+
import { emitScript, type Shell } from "./emit";
|
|
14
|
+
|
|
15
|
+
export function runShell(shell: Shell, usage: string, args: string[], ctx: CliContext & { session?: Session }): number {
|
|
16
|
+
try {
|
|
17
|
+
if (readVerbArgs(`completion ${shell}`, args, ctx, { help: usage }) === null) return 0;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (e instanceof CliExit) return e.code;
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
ctx.stdout.write(emitScript(shell, ctx.session?.cli.registry));
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zsh.ts — `eris completion zsh`: the zsh completion script on stdout.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CliContext } from "../../../main";
|
|
6
|
+
import type { Session } from "../../compose";
|
|
7
|
+
import { runShell } from "./shell";
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: eris completion zsh
|
|
10
|
+
|
|
11
|
+
Emit the zsh completion script to stdout. Load it into the current shell
|
|
12
|
+
or make it permanent:
|
|
13
|
+
|
|
14
|
+
source <(eris completion zsh) # this shell only
|
|
15
|
+
echo 'source <(eris completion zsh)' >> ~/.zshrc # every shell
|
|
16
|
+
|
|
17
|
+
Implementation: zsh loads the same completion body as bash through
|
|
18
|
+
bashcompinit (one body, two thin wrappers — the lower-maintenance route).
|
|
19
|
+
See docs/COMPLETION.md.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export function runZsh(args: string[], ctx: CliContext & { session?: Session }): number {
|
|
23
|
+
return runShell("zsh", USAGE, args, ctx);
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* completion.ts — `eris completion <shell>`: emit a shell tab-completion
|
|
3
|
+
* script. The script is generated from the verb registry
|
|
4
|
+
* (./completion/emit.ts); `bash` is ./completion/bash.ts, `zsh`
|
|
5
|
+
* ./completion/zsh.ts.
|
|
6
|
+
*
|
|
7
|
+
* It runs on every shell start (`source <(eris completion bash)`), so its
|
|
8
|
+
* import graph is the registry and nothing else: no other verb, and no Bash.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { groupArgs } from "../args";
|
|
12
|
+
import type { CliContext } from "../../main";
|
|
13
|
+
import type { Session } from "../compose";
|
|
14
|
+
import { runBash } from "./completion/bash";
|
|
15
|
+
import { runZsh } from "./completion/zsh";
|
|
16
|
+
|
|
17
|
+
const USAGE_HEAD = `Usage: eris completion <shell>
|
|
18
|
+
|
|
19
|
+
Shells:
|
|
20
|
+
bash Emit the bash completion script (bash 3.2+ clean)
|
|
21
|
+
zsh Emit the zsh completion script (bashcompinit route)
|
|
22
|
+
|
|
23
|
+
Load into the current shell: source <(eris completion bash)
|
|
24
|
+
Make permanent: echo 'source <(eris completion bash)' >> ~/.bashrc
|
|
25
|
+
echo 'source <(eris completion zsh)' >> ~/.zshrc
|
|
26
|
+
|
|
27
|
+
Completes command groups, verbs, flags and file-valued flags. A tab press
|
|
28
|
+
never runs eris and never touches the network.
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const USAGE_TAIL = `See docs/COMPLETION.md.
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
/** Core's text, with a distribution's paragraph (`composeCli`'s `completionNote`) between the two halves. */
|
|
35
|
+
function usageText(note: string | null): string {
|
|
36
|
+
return `${USAGE_HEAD}${note === null ? "" : `${note}\n`}${USAGE_TAIL}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runCompletion(args: string[], ctx: CliContext & { session?: Session }): Promise<number> {
|
|
40
|
+
const g = groupArgs("completion", args);
|
|
41
|
+
if (g.kind === "sub") return (g.sub === "bash" ? runBash : runZsh)(g.rest, ctx);
|
|
42
|
+
if (g.kind === "bare" || g.kind === "help") {
|
|
43
|
+
ctx.stdout.write(usageText(ctx.session?.cli.completionNote ?? null));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
ctx.stderr.write(`ERROR Unknown completion shell: ${g.sub} (supported: bash, zsh)\n`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|