@loomcli/core 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application.d.ts +20 -0
- package/dist/application.js +207 -75
- package/dist/chain.d.ts +49 -0
- package/dist/chain.js +288 -0
- package/dist/command.d.ts +72 -21
- package/dist/command.js +142 -28
- package/dist/errors.d.ts +10 -2
- package/dist/errors.js +33 -8
- package/dist/extension.d.ts +95 -0
- package/dist/extension.js +313 -0
- package/dist/facts.d.ts +39 -0
- package/dist/facts.js +95 -0
- package/dist/globals.d.ts +39 -7
- package/dist/globals.js +109 -12
- package/dist/index.d.ts +7 -1
- package/dist/index.js +2 -0
- package/dist/inspect.d.ts +44 -10
- package/dist/inspect.js +64 -21
- package/dist/options.d.ts +7 -0
- package/dist/options.js +9 -0
- package/dist/plugin.d.ts +116 -0
- package/dist/plugin.js +250 -0
- package/dist/signals.d.ts +52 -0
- package/dist/signals.js +85 -0
- package/dist/types.d.ts +55 -7
- package/dist/validation.d.ts +4 -2
- package/dist/validation.js +17 -15
- package/package.json +1 -1
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { DeclarationError } from './errors.js';
|
|
2
|
+
import { buildExtensions, isDescriptor, registerDescriptor } from './extension.js';
|
|
3
|
+
import { checkDeprecated, checkDescription, checkHidden, isPlainObject } from './facts.js';
|
|
4
|
+
import { isProcessSignal } from './signals.js';
|
|
5
|
+
import { captureConfig, checkDeclarations } from './validation.js';
|
|
6
|
+
/** Authored values register here, so the public type publishes no state to reach or replace. */
|
|
7
|
+
const nodes = new WeakMap();
|
|
8
|
+
/**
|
|
9
|
+
* The runtime value `plugin()` returns. `Options` appears in a read position alone, which makes it
|
|
10
|
+
* covariant: a `plugins` list holds plugins with different options the way `failures` holds
|
|
11
|
+
* renderers for different classes, and `Middleware` and `load` accept a narrower plugin.
|
|
12
|
+
*/
|
|
13
|
+
class PluginDeclaration {
|
|
14
|
+
constructor(node) {
|
|
15
|
+
nodes.set(this, node);
|
|
16
|
+
Object.freeze(this);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One plugin: an identity and the declarations it contributes. The value performs no work when it
|
|
21
|
+
* is created and none when it is installed, so an installed plugin an invocation never reaches
|
|
22
|
+
* costs that invocation nothing.
|
|
23
|
+
*/
|
|
24
|
+
function plugin(identity, definition) {
|
|
25
|
+
return new PluginDeclaration({ definition, identity });
|
|
26
|
+
}
|
|
27
|
+
/** How every plugin diagnostic names one plugin at the start of a sentence. */
|
|
28
|
+
function pluginSentence(identity) {
|
|
29
|
+
return `Plugin "${identity}"`;
|
|
30
|
+
}
|
|
31
|
+
/** Reads the declarations behind an installed value; anything else is a declaration error. */
|
|
32
|
+
function nodeOf(value) {
|
|
33
|
+
const node = typeof value === 'object' && value !== null ? nodes.get(value) : undefined;
|
|
34
|
+
if (!node) {
|
|
35
|
+
throw new DeclarationError('The Application holds a value that is not a plugin. Supply the value returned by plugin(identity, definition).');
|
|
36
|
+
}
|
|
37
|
+
return node;
|
|
38
|
+
}
|
|
39
|
+
/** The identity one installed value declares, which is a nonempty string installed once. */
|
|
40
|
+
function readIdentity(node, installed) {
|
|
41
|
+
const { identity } = node;
|
|
42
|
+
if (typeof identity !== 'string') {
|
|
43
|
+
throw new DeclarationError('A plugin declares an identity that is not a string. Supply a nonempty string, such as the package name.');
|
|
44
|
+
}
|
|
45
|
+
if (identity === '') {
|
|
46
|
+
throw new DeclarationError('A plugin declares an empty identity. Supply a nonempty string, such as the package name.');
|
|
47
|
+
}
|
|
48
|
+
if (installed.has(identity)) {
|
|
49
|
+
throw new DeclarationError(`The Application installs plugin "${identity}" twice. Install each plugin once.`);
|
|
50
|
+
}
|
|
51
|
+
return identity;
|
|
52
|
+
}
|
|
53
|
+
/** The declarations one plugin value carries, which a JavaScript author reaches as any value. */
|
|
54
|
+
function definitionOf(identity, node) {
|
|
55
|
+
const { definition } = node;
|
|
56
|
+
if (!isPlainObject(definition)) {
|
|
57
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares a definition that is not an object. Supply { options, middleware, extensions, failures }.`);
|
|
58
|
+
}
|
|
59
|
+
return definition;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The installed list in composition order, with the rules that read the list itself. The slot is
|
|
63
|
+
* read defensively, because a JavaScript author reaches it with any value. Each plugin's own
|
|
64
|
+
* declarations are read by the build steps that consume them, in the order those steps run.
|
|
65
|
+
*/
|
|
66
|
+
function installPlugins(plugins) {
|
|
67
|
+
if (!Array.isArray(plugins)) {
|
|
68
|
+
throw new DeclarationError('The Application plugins must be an array. Supply a list of plugin values.');
|
|
69
|
+
}
|
|
70
|
+
const installed = [];
|
|
71
|
+
const identities = new Set();
|
|
72
|
+
for (const value of plugins) {
|
|
73
|
+
const node = nodeOf(value);
|
|
74
|
+
const identity = readIdentity(node, identities);
|
|
75
|
+
identities.add(identity);
|
|
76
|
+
installed.push({ declaration: definitionOf(identity, node), identity });
|
|
77
|
+
}
|
|
78
|
+
return installed;
|
|
79
|
+
}
|
|
80
|
+
/** The keys a plugin option may not declare, in the order its diagnostic names them. */
|
|
81
|
+
const forbidden = ['validate', 'validateOmitted', 'required'];
|
|
82
|
+
/** The rules a plugin option answers before every rule an ordinary declaration carries. */
|
|
83
|
+
function checkPluginOption(sentence, config) {
|
|
84
|
+
if (!isPlainObject(config)) {
|
|
85
|
+
throw new DeclarationError(`${sentence} is not an option declaration. Supply { type, ... }.`);
|
|
86
|
+
}
|
|
87
|
+
const rejected = forbidden.find((key) => key in config);
|
|
88
|
+
if (rejected !== undefined) {
|
|
89
|
+
throw new DeclarationError(`${sentence} declares ${rejected}. Remove it; a plugin option carries no schema or presence rule, and the middleware interprets the value.`);
|
|
90
|
+
}
|
|
91
|
+
checkDescription(sentence, config.description);
|
|
92
|
+
checkHidden(sentence, config.hidden);
|
|
93
|
+
checkDeprecated(sentence, config.deprecated);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One plugin's option declarations, in declaration order. They join the globals table, so the rules
|
|
97
|
+
* that pair them with another scope's options belong to that table and not to this reading.
|
|
98
|
+
*/
|
|
99
|
+
function readOptions(identity, declared, build) {
|
|
100
|
+
if (declared !== undefined && !isPlainObject(declared)) {
|
|
101
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares options that are not an object. Supply a record of option declarations.`);
|
|
102
|
+
}
|
|
103
|
+
const inputs = [];
|
|
104
|
+
for (const [name, config] of Object.entries(declared ?? {})) {
|
|
105
|
+
const sentence = `${pluginSentence(identity)} option "${name}"`;
|
|
106
|
+
checkPluginOption(sentence, config);
|
|
107
|
+
const input = { config: captureConfig(config), kind: 'option', name };
|
|
108
|
+
// The shared rules name the plugin and the option, so a fault reads with its contributor.
|
|
109
|
+
checkDeclarations([input], sentence);
|
|
110
|
+
build.extensions.set(input, buildExtensions({
|
|
111
|
+
declared: config.extensions,
|
|
112
|
+
descriptors: build.descriptors,
|
|
113
|
+
subject: {
|
|
114
|
+
phrase: `on ${sentence.slice(0, 1).toLowerCase()}${sentence.slice(1)}`,
|
|
115
|
+
sentence,
|
|
116
|
+
},
|
|
117
|
+
target: 'option',
|
|
118
|
+
}));
|
|
119
|
+
inputs.push(input);
|
|
120
|
+
}
|
|
121
|
+
return inputs;
|
|
122
|
+
}
|
|
123
|
+
/** One activation name, which must be one of the plugin's own declared options. */
|
|
124
|
+
function readActivationName(identity, name, names) {
|
|
125
|
+
if (typeof name !== 'string' || !names.has(name)) {
|
|
126
|
+
throw new DeclarationError(`${pluginSentence(identity)} activates middleware on option "${String(name)}", which it does not declare. Name one of the plugin's own options.`);
|
|
127
|
+
}
|
|
128
|
+
return name;
|
|
129
|
+
}
|
|
130
|
+
/** The activation a middleware declares, checked against the options its own plugin declares. */
|
|
131
|
+
function readActivation(identity, declared, names) {
|
|
132
|
+
if (declared === 'always') {
|
|
133
|
+
return 'always';
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(declared)) {
|
|
136
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares middleware with no activation. Supply activate: 'always' or a list of the plugin's own option names.`);
|
|
137
|
+
}
|
|
138
|
+
const list = declared;
|
|
139
|
+
if (list.length === 0) {
|
|
140
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares middleware with an empty activation list. Name at least one of the plugin's options or use 'always'.`);
|
|
141
|
+
}
|
|
142
|
+
return list.map((name) => readActivationName(identity, name, names));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The loader a middleware declares. Core calls it with no arguments and reads whatever it resolves
|
|
146
|
+
* to, so being callable is the whole runtime claim this check makes.
|
|
147
|
+
*/
|
|
148
|
+
function isLoader(value) {
|
|
149
|
+
return typeof value === 'function';
|
|
150
|
+
}
|
|
151
|
+
/** One plugin's middleware, or `undefined` for a plugin that declares none. */
|
|
152
|
+
function readMiddleware(identity, declared, names) {
|
|
153
|
+
if (declared === undefined) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
if (!isPlainObject(declared)) {
|
|
157
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares middleware that is not an object. Supply { activate, load }.`);
|
|
158
|
+
}
|
|
159
|
+
const activate = readActivation(identity, declared.activate, names);
|
|
160
|
+
const { load } = declared;
|
|
161
|
+
if (!isLoader(load)) {
|
|
162
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares middleware with no load function. Supply load: () => import('./middleware.js').`);
|
|
163
|
+
}
|
|
164
|
+
return { activate, load };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* One plugin's claim on the signals slot, drawn from the closed set core installs listeners for.
|
|
168
|
+
* An empty list claims nothing, so it leaves the slot free for another plugin.
|
|
169
|
+
* Each signal is claimed once, because core installs one listener per entry and a second listener
|
|
170
|
+
* on one signal would take the force path on the first signal the run receives.
|
|
171
|
+
*/
|
|
172
|
+
function readSignals(identity, declared) {
|
|
173
|
+
if (declared === undefined) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(declared)) {
|
|
177
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares signals that are not an array. Supply a list of signal names.`);
|
|
178
|
+
}
|
|
179
|
+
const list = declared;
|
|
180
|
+
const claimed = new Set();
|
|
181
|
+
for (const value of list) {
|
|
182
|
+
if (!isProcessSignal(value)) {
|
|
183
|
+
throw new DeclarationError(`${pluginSentence(identity)} claims signal "${String(value)}". Claim SIGINT or SIGTERM.`);
|
|
184
|
+
}
|
|
185
|
+
if (claimed.has(value)) {
|
|
186
|
+
throw new DeclarationError(`${pluginSentence(identity)} claims signal "${value}" twice. Claim each signal once.`);
|
|
187
|
+
}
|
|
188
|
+
claimed.add(value);
|
|
189
|
+
}
|
|
190
|
+
return [...claimed];
|
|
191
|
+
}
|
|
192
|
+
/** A plugin's own list names the extensions it defines, before any declaration carries one. */
|
|
193
|
+
function defineExtensions(identity, declaration, build) {
|
|
194
|
+
const { extensions } = declaration;
|
|
195
|
+
if (extensions !== undefined && !Array.isArray(extensions)) {
|
|
196
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares extensions that are not an array. Supply a list of extension descriptors.`);
|
|
197
|
+
}
|
|
198
|
+
for (const descriptor of extensions ?? []) {
|
|
199
|
+
if (!isDescriptor(descriptor)) {
|
|
200
|
+
throw new DeclarationError(`${pluginSentence(identity)} holds a value that is not an extension. Supply the value returned by extension(identity, config).`);
|
|
201
|
+
}
|
|
202
|
+
registerDescriptor(build.descriptors, descriptor);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** One plugin's failure registrations, which are a list before any of them is read. */
|
|
206
|
+
function readFailures(identity, declared) {
|
|
207
|
+
if (declared === undefined) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(declared)) {
|
|
211
|
+
throw new DeclarationError(`${pluginSentence(identity)} declares failures that are not an array. Supply a list of renderFailure values.`);
|
|
212
|
+
}
|
|
213
|
+
return declared;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Every installed plugin's declarations, in installation order. A plugin's own extensions register
|
|
217
|
+
* before any declaration carries a value, so a duplicated package copy is reported from the list
|
|
218
|
+
* that installed it.
|
|
219
|
+
*/
|
|
220
|
+
function buildPlugins(installed, build) {
|
|
221
|
+
/**
|
|
222
|
+
* The signals slot has one owner, so the first plugin to claim it names the second claimant's
|
|
223
|
+
* diagnostic. An empty claim leaves the slot free.
|
|
224
|
+
*/
|
|
225
|
+
let owner = undefined;
|
|
226
|
+
return installed.map(({ declaration, identity }) => {
|
|
227
|
+
defineExtensions(identity, declaration, build);
|
|
228
|
+
const inputs = readOptions(identity, declaration.options, build);
|
|
229
|
+
const names = new Set(inputs.map((input) => input.name));
|
|
230
|
+
const signals = readSignals(identity, declaration.signals);
|
|
231
|
+
if (signals.length > 0) {
|
|
232
|
+
if (owner !== undefined) {
|
|
233
|
+
throw new DeclarationError(`${pluginSentence(identity)} claims the signals slot, which plugin "${owner}" already holds. Install one owner.`);
|
|
234
|
+
}
|
|
235
|
+
owner = identity;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
failures: readFailures(identity, declaration.failures),
|
|
239
|
+
identity,
|
|
240
|
+
inputs,
|
|
241
|
+
middleware: readMiddleware(identity, declaration.middleware, names),
|
|
242
|
+
signals,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/** The signals the one slot owner claimed, or none when no installed plugin claims the slot. */
|
|
247
|
+
function ownedSignals(plugins) {
|
|
248
|
+
return plugins.find((entry) => entry.signals.length > 0)?.signals ?? [];
|
|
249
|
+
}
|
|
250
|
+
export { buildPlugins, installPlugins, ownedSignals, plugin, pluginSentence };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** The signals a plugin may claim, which is the closed set core installs process listeners for. */
|
|
2
|
+
type ProcessSignal = 'SIGINT' | 'SIGTERM';
|
|
3
|
+
/**
|
|
4
|
+
* What aborted one run's private controller. Core owns this value, so a middleware reads `source`
|
|
5
|
+
* and never infers a signal name; `cause` carries the caller's own `signal.reason` when a caller
|
|
6
|
+
* aborted. The first cause to abort fixes the reason and the code, and a later cause changes
|
|
7
|
+
* neither.
|
|
8
|
+
*/
|
|
9
|
+
interface CancellationReason {
|
|
10
|
+
source: ProcessSignal | 'caller';
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
}
|
|
13
|
+
/** The status each cause resolves. A script that saw 0 after an interrupt would carry on. */
|
|
14
|
+
declare const codes: {
|
|
15
|
+
readonly SIGINT: 130;
|
|
16
|
+
readonly SIGTERM: 143;
|
|
17
|
+
readonly caller: 130;
|
|
18
|
+
};
|
|
19
|
+
/** The status one cancelled run resolves, which is the part of `ExitCode` a signal decides. */
|
|
20
|
+
type CancellationCode = (typeof codes)[CancellationReason['source']];
|
|
21
|
+
/** Whether one declared value names a signal core installs a listener for. */
|
|
22
|
+
declare function isProcessSignal(value: unknown): value is ProcessSignal;
|
|
23
|
+
/** The status one cancelled run resolves, which the first cause to abort fixed. */
|
|
24
|
+
declare function cancellationCode(reason: CancellationReason): CancellationCode;
|
|
25
|
+
/**
|
|
26
|
+
* Whether one thrown value is the cancellation the run already reports: the reason core aborted
|
|
27
|
+
* with, which an API that rejects with `signal.reason` throws back, or an error every runtime
|
|
28
|
+
* names `AbortError`. The chain wraps an unexpected throw, so the wrapped cause reads the same.
|
|
29
|
+
*/
|
|
30
|
+
declare function isCancellationEcho(thrown: unknown, reason: unknown): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The process listeners one run holds, and the caller subscription it opened at run entry. Install
|
|
33
|
+
* and removal sit together so the bracket a run keeps is readable in one place.
|
|
34
|
+
*/
|
|
35
|
+
interface SignalBracket {
|
|
36
|
+
/** Installs one listener per claimed signal, once the graph has built and validated. */
|
|
37
|
+
install: (owned: readonly ProcessSignal[]) => void;
|
|
38
|
+
/** The reason the first cause fixed, or `undefined` while nothing has aborted the run. */
|
|
39
|
+
reason: () => CancellationReason | undefined;
|
|
40
|
+
/** Removes every listener this run holds. Called on the run's last exit path. */
|
|
41
|
+
finish: () => void;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The bracket for one run. Core subscribes to a caller's signal at run entry, and installs its own
|
|
45
|
+
* process listeners only for the plugin that owns the signals slot, only after the graph has built,
|
|
46
|
+
* and only until the run resolves. A process signal that arrives once the run is already cancelled
|
|
47
|
+
* is the force path: core removes its own listeners and re-raises, so the default disposition ends
|
|
48
|
+
* the process when no other listener remains. Core does not own the process.
|
|
49
|
+
*/
|
|
50
|
+
declare function bracketRun(controller: AbortController, caller: AbortSignal | undefined): SignalBracket;
|
|
51
|
+
export type { CancellationCode, CancellationReason, ProcessSignal, SignalBracket };
|
|
52
|
+
export { bracketRun, cancellationCode, isCancellationEcho, isProcessSignal };
|
package/dist/signals.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { InternalError } from './errors.js';
|
|
2
|
+
/** The status each cause resolves. A script that saw 0 after an interrupt would carry on. */
|
|
3
|
+
const codes = { SIGINT: 130, SIGTERM: 143, caller: 130 };
|
|
4
|
+
/** The closed set a claim is drawn from, as the values a runtime signal name may take. */
|
|
5
|
+
const claimable = new Set(['SIGINT', 'SIGTERM']);
|
|
6
|
+
/** Whether one declared value names a signal core installs a listener for. */
|
|
7
|
+
function isProcessSignal(value) {
|
|
8
|
+
return typeof value === 'string' && claimable.has(value);
|
|
9
|
+
}
|
|
10
|
+
/** The status one cancelled run resolves, which the first cause to abort fixed. */
|
|
11
|
+
function cancellationCode(reason) {
|
|
12
|
+
return codes[reason.source];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether one thrown value is the cancellation the run already reports: the reason core aborted
|
|
16
|
+
* with, which an API that rejects with `signal.reason` throws back, or an error every runtime
|
|
17
|
+
* names `AbortError`. The chain wraps an unexpected throw, so the wrapped cause reads the same.
|
|
18
|
+
*/
|
|
19
|
+
function isCancellationEcho(thrown, reason) {
|
|
20
|
+
if (thrown === reason) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (thrown instanceof Error && thrown.name === 'AbortError') {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return thrown instanceof InternalError && isCancellationEcho(thrown.cause, reason);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The bracket for one run. Core subscribes to a caller's signal at run entry, and installs its own
|
|
30
|
+
* process listeners only for the plugin that owns the signals slot, only after the graph has built,
|
|
31
|
+
* and only until the run resolves. A process signal that arrives once the run is already cancelled
|
|
32
|
+
* is the force path: core removes its own listeners and re-raises, so the default disposition ends
|
|
33
|
+
* the process when no other listener remains. Core does not own the process.
|
|
34
|
+
*/
|
|
35
|
+
function bracketRun(controller, caller) {
|
|
36
|
+
let reason = undefined;
|
|
37
|
+
let held = [];
|
|
38
|
+
const release = () => {
|
|
39
|
+
for (const entry of held) {
|
|
40
|
+
process.off(entry.signal, entry.handler);
|
|
41
|
+
}
|
|
42
|
+
held = [];
|
|
43
|
+
};
|
|
44
|
+
const cancel = (next) => {
|
|
45
|
+
if (reason) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
reason = next;
|
|
49
|
+
controller.abort(next);
|
|
50
|
+
};
|
|
51
|
+
const received = (signal) => {
|
|
52
|
+
if (reason) {
|
|
53
|
+
release();
|
|
54
|
+
process.kill(process.pid, signal);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
cancel({ source: signal });
|
|
58
|
+
};
|
|
59
|
+
const aborted = () => {
|
|
60
|
+
cancel({ cause: caller?.reason, source: 'caller' });
|
|
61
|
+
};
|
|
62
|
+
if (caller?.aborted === true) {
|
|
63
|
+
aborted();
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
caller?.addEventListener('abort', aborted);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
finish: () => {
|
|
70
|
+
release();
|
|
71
|
+
caller?.removeEventListener('abort', aborted);
|
|
72
|
+
},
|
|
73
|
+
install: (owned) => {
|
|
74
|
+
for (const signal of owned) {
|
|
75
|
+
const handler = () => {
|
|
76
|
+
received(signal);
|
|
77
|
+
};
|
|
78
|
+
process.on(signal, handler);
|
|
79
|
+
held.push({ handler, signal });
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
reason: () => reason,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export { bracketRun, cancellationCode, isCancellationEcho, isProcessSignal };
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="node" preserve="true" />
|
|
2
2
|
import type { Readable, Writable } from 'node:stream';
|
|
3
3
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
4
|
+
import type { ExtensionValue } from './extension.js';
|
|
4
5
|
type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
|
|
5
6
|
type ShortAlias = LowercaseLetter | Uppercase<LowercaseLetter>;
|
|
6
7
|
type OptionSpelling = {
|
|
@@ -32,6 +33,31 @@ type Omission = {
|
|
|
32
33
|
} | {
|
|
33
34
|
validateOmitted?: false;
|
|
34
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* The one-line summary every projection reads. It is a core fact: optional, and a string that holds
|
|
38
|
+
* a character other than whitespace and no line terminator.
|
|
39
|
+
*/
|
|
40
|
+
interface Described {
|
|
41
|
+
description?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The two core facts a listing reads on a named Command and on an option.
|
|
45
|
+
* `hidden` keeps the member off every listing, and an omitted one reads `false`.
|
|
46
|
+
* `deprecated` is the one-line migration message a listing shows beside the member.
|
|
47
|
+
* Neither belongs to an argument, which cannot leave the grammar it sits in, or to the root.
|
|
48
|
+
*/
|
|
49
|
+
interface Listed {
|
|
50
|
+
hidden?: boolean;
|
|
51
|
+
deprecated?: string;
|
|
52
|
+
}
|
|
53
|
+
/** The extension values one option declaration carries, whatever scope declares the option. */
|
|
54
|
+
interface OptionExtensions {
|
|
55
|
+
extensions?: readonly ExtensionValue<'option'>[];
|
|
56
|
+
}
|
|
57
|
+
/** The same slot on an argument declaration, typed by the target its values must name. */
|
|
58
|
+
interface ArgumentExtensions {
|
|
59
|
+
extensions?: readonly ExtensionValue<'argument'>[];
|
|
60
|
+
}
|
|
35
61
|
/**
|
|
36
62
|
* The tokens one declaration collects before validation: one string, or the whole collection. A
|
|
37
63
|
* multiple option and a variadic argument collect alike, so they share this raw shape.
|
|
@@ -54,7 +80,7 @@ export type GlobalNameConstraint<Name extends string, Globals> = Name extends ke
|
|
|
54
80
|
} : unknown;
|
|
55
81
|
/** A required record key excludes open strings; distribution rejects each union member. */
|
|
56
82
|
export type NameConstraint<Name extends string, Whole extends string = Name> = {} extends Record<Name, unknown> ? LiteralNameFault : Name extends Whole ? [Whole] extends [Name] ? unknown : LiteralNameFault : LiteralNameFault;
|
|
57
|
-
export type ExitCode = 0 | 1 | 2;
|
|
83
|
+
export type ExitCode = 0 | 1 | 2 | 130 | 143;
|
|
58
84
|
export interface InputTerminal {
|
|
59
85
|
isTTY: boolean;
|
|
60
86
|
}
|
|
@@ -77,6 +103,11 @@ export interface Host {
|
|
|
77
103
|
}
|
|
78
104
|
export interface RunOptions {
|
|
79
105
|
host?: Partial<Host>;
|
|
106
|
+
/**
|
|
107
|
+
* A caller-owned signal that cancels the run. Core subscribes to it at run entry and honors an
|
|
108
|
+
* abort at every phase boundary; it composes with an installed signals owner.
|
|
109
|
+
*/
|
|
110
|
+
signal?: AbortSignal;
|
|
80
111
|
}
|
|
81
112
|
/** The declaration one schema call validates, under the name and the scope it was declared in. */
|
|
82
113
|
export interface InputIdentity {
|
|
@@ -130,17 +161,17 @@ export interface Out {
|
|
|
130
161
|
render<Data>(data: Data, renderer: Renderer<Data>): Promise<void>;
|
|
131
162
|
fatal(message: string): never;
|
|
132
163
|
}
|
|
133
|
-
export type StringOption = OptionSpelling & Presence & Multiplicity & Omission & {
|
|
164
|
+
export type StringOption = OptionSpelling & Presence & Multiplicity & Omission & Described & Listed & OptionExtensions & {
|
|
134
165
|
type: 'string';
|
|
135
166
|
polarity?: never;
|
|
136
167
|
validate?: StandardSchemaV1;
|
|
137
168
|
};
|
|
138
169
|
/** A variadic argument collects the remaining tokens, so it follows the multiple option rules. */
|
|
139
|
-
export type VariadicArgument = Presence & {
|
|
170
|
+
export type VariadicArgument = Presence & Described & ArgumentExtensions & {
|
|
140
171
|
variadic: true;
|
|
141
172
|
validate?: StandardSchemaV1;
|
|
142
173
|
};
|
|
143
|
-
export type ScalarArgument = Presence & Omission & {
|
|
174
|
+
export type ScalarArgument = Presence & Omission & Described & ArgumentExtensions & {
|
|
144
175
|
variadic?: false;
|
|
145
176
|
validate?: StandardSchemaV1;
|
|
146
177
|
};
|
|
@@ -198,7 +229,7 @@ export type ArgumentValue<Config extends ArgumentConfig> = Config extends {
|
|
|
198
229
|
} | {
|
|
199
230
|
validateOmitted: true;
|
|
200
231
|
} ? never : undefined);
|
|
201
|
-
export type BooleanOption = (OptionSpelling & {
|
|
232
|
+
export type BooleanOption = (OptionSpelling & Described & Listed & OptionExtensions & {
|
|
202
233
|
type: 'boolean';
|
|
203
234
|
validate?: never;
|
|
204
235
|
default?: never;
|
|
@@ -206,7 +237,7 @@ export type BooleanOption = (OptionSpelling & {
|
|
|
206
237
|
required?: never;
|
|
207
238
|
validateOmitted?: never;
|
|
208
239
|
polarity?: 'positive' | 'negative';
|
|
209
|
-
}) | {
|
|
240
|
+
}) | (Described & Listed & OptionExtensions & {
|
|
210
241
|
type: 'boolean';
|
|
211
242
|
validate?: never;
|
|
212
243
|
default?: never;
|
|
@@ -216,8 +247,23 @@ export type BooleanOption = (OptionSpelling & {
|
|
|
216
247
|
polarity: 'both';
|
|
217
248
|
short?: ShortAlias;
|
|
218
249
|
shortOnly?: false;
|
|
219
|
-
};
|
|
250
|
+
});
|
|
220
251
|
export type OptionConfig = StringOption | BooleanOption;
|
|
252
|
+
/**
|
|
253
|
+
* The parsing part of a string option config, which is all a plugin option declares. A plugin
|
|
254
|
+
* option carries no schema and no presence rule, because it is read before local parsing, where the
|
|
255
|
+
* validation context every schema is promised cannot exist. Its middleware interprets the value.
|
|
256
|
+
* A Boolean plugin option is an ordinary `BooleanOption`, which already declares none of them.
|
|
257
|
+
*/
|
|
258
|
+
export type PluginStringOption = OptionSpelling & Multiplicity & Described & Listed & OptionExtensions & {
|
|
259
|
+
type: 'string';
|
|
260
|
+
default?: string | string[];
|
|
261
|
+
polarity?: never;
|
|
262
|
+
required?: never;
|
|
263
|
+
validate?: never;
|
|
264
|
+
validateOmitted?: never;
|
|
265
|
+
};
|
|
266
|
+
export type PluginOptionConfig = PluginStringOption | BooleanOption;
|
|
221
267
|
export type OptionValue<Config extends OptionConfig> = Config extends StringOption ? Config extends {
|
|
222
268
|
multiple: true;
|
|
223
269
|
} ? ValidatedValue<Config, string[]> : ValidatedValue<Config, string> | (Config extends {
|
|
@@ -233,6 +279,8 @@ export interface ActionContext<Args, Options = {}> {
|
|
|
233
279
|
passthrough: string[];
|
|
234
280
|
out: Out;
|
|
235
281
|
host: Host;
|
|
282
|
+
/** The run's cancellation signal, which a caller or an installed signals owner aborts. */
|
|
283
|
+
signal: AbortSignal;
|
|
236
284
|
}
|
|
237
285
|
export type Action<Args, Options = {}> = (context: ActionContext<Args, Options>) => unknown;
|
|
238
286
|
/** Phantom key. It keeps the inferred declaration types exact and holds no runtime value. */
|
package/dist/validation.d.ts
CHANGED
|
@@ -72,9 +72,11 @@ export declare function issuePath(issue: StandardSchemaV1.Issue): string | undef
|
|
|
72
72
|
/**
|
|
73
73
|
* Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
|
|
74
74
|
* `run()` apply exactly the same rules, and only validating a default through its schema, which
|
|
75
|
-
* can be asynchronous, is left to `run()`.
|
|
75
|
+
* can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
|
|
76
|
+
* a plugin, supplies the subject its diagnostics read with; every other caller is named by the
|
|
77
|
+
* declaration itself.
|
|
76
78
|
*/
|
|
77
|
-
export declare function checkDeclarations(inputs: readonly InputDeclaration[]): void;
|
|
79
|
+
export declare function checkDeclarations(inputs: readonly InputDeclaration[], named?: string): void;
|
|
78
80
|
/**
|
|
79
81
|
* Every declared default, validated before any token is read. The host is captured by then, so a
|
|
80
82
|
* default's schema reads the same Host its action will, under the `default` phase.
|
package/dist/validation.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { schemaOptions } from './context.js';
|
|
2
2
|
import { DeclarationError, InputError } from './errors.js';
|
|
3
|
+
import { booleanValue } from './options.js';
|
|
3
4
|
/** Every declaration in validation order: the globals first, then the reading Command's own. */
|
|
4
5
|
function scoped(inputs) {
|
|
5
6
|
return [
|
|
@@ -117,9 +118,8 @@ function holdsRawDefault(input) {
|
|
|
117
118
|
* `validateOmitted: true` is the one way an omitted scalar reaches its schema, so every other rule
|
|
118
119
|
* that already decides absence rejects it, and the flag needs a schema to receive the omission.
|
|
119
120
|
*/
|
|
120
|
-
function checkOmissionValidation(input) {
|
|
121
|
+
function checkOmissionValidation(input, subject) {
|
|
121
122
|
const { config } = input;
|
|
122
|
-
const subject = declaredName(input);
|
|
123
123
|
if (config.required) {
|
|
124
124
|
throw new DeclarationError(`${subject} is required and declares validateOmitted. Remove validateOmitted or make the input optional.`);
|
|
125
125
|
}
|
|
@@ -133,34 +133,34 @@ function checkOmissionValidation(input) {
|
|
|
133
133
|
throw new DeclarationError(`${subject} declares validateOmitted without a schema. Add validate or remove validateOmitted.`);
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
-
function checkDeclaration(input) {
|
|
136
|
+
function checkDeclaration(input, subject) {
|
|
137
137
|
const { config } = input;
|
|
138
138
|
if (input.kind === 'option' && input.config.type === 'boolean') {
|
|
139
139
|
if ('validate' in config ||
|
|
140
140
|
'default' in config ||
|
|
141
141
|
'required' in config ||
|
|
142
142
|
'validateOmitted' in config) {
|
|
143
|
-
throw new DeclarationError(`${
|
|
143
|
+
throw new DeclarationError(`${subject} is Boolean. Remove validate, default, required, and validateOmitted; use polarity to control its absent value.`);
|
|
144
144
|
}
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
147
|
if (config.required !== undefined && typeof config.required !== 'boolean') {
|
|
148
|
-
throw new DeclarationError(`${
|
|
148
|
+
throw new DeclarationError(`${subject} required must be Boolean. Use true or false.`);
|
|
149
149
|
}
|
|
150
150
|
if (input.kind === 'argument' &&
|
|
151
151
|
input.config.variadic !== undefined &&
|
|
152
152
|
typeof input.config.variadic !== 'boolean') {
|
|
153
|
-
throw new DeclarationError(`${
|
|
153
|
+
throw new DeclarationError(`${subject} variadic must be Boolean. Use true or false.`);
|
|
154
154
|
}
|
|
155
155
|
// The test reads presence, not truth, so a declared `undefined` is a declaration to reject.
|
|
156
156
|
if ('validateOmitted' in config && typeof config.validateOmitted !== 'boolean') {
|
|
157
|
-
throw new DeclarationError(`${
|
|
157
|
+
throw new DeclarationError(`${subject} validateOmitted must be Boolean. Use true or false.`);
|
|
158
158
|
}
|
|
159
159
|
if (config.required && Object.hasOwn(config, 'default')) {
|
|
160
|
-
throw new DeclarationError(`${
|
|
160
|
+
throw new DeclarationError(`${subject} is required and declares a default. Remove the default or make the input optional.`);
|
|
161
161
|
}
|
|
162
162
|
if (validatesOmission(input)) {
|
|
163
|
-
checkOmissionValidation(input);
|
|
163
|
+
checkOmissionValidation(input, subject);
|
|
164
164
|
}
|
|
165
165
|
const schema = config.validate;
|
|
166
166
|
if (schema !== undefined &&
|
|
@@ -170,7 +170,7 @@ function checkDeclaration(input) {
|
|
|
170
170
|
schema['~standard'].version !== 1 ||
|
|
171
171
|
typeof schema['~standard'].vendor !== 'string' ||
|
|
172
172
|
typeof schema['~standard'].validate !== 'function')) {
|
|
173
|
-
throw new DeclarationError(`${
|
|
173
|
+
throw new DeclarationError(`${subject} validate must be a Standard Schema v1 object. Supply a compatible schema.`);
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
function readIssue(issue) {
|
|
@@ -259,15 +259,17 @@ function hasDefault(input) {
|
|
|
259
259
|
/**
|
|
260
260
|
* Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
|
|
261
261
|
* `run()` apply exactly the same rules, and only validating a default through its schema, which
|
|
262
|
-
* can be asynchronous, is left to `run()`.
|
|
262
|
+
* can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
|
|
263
|
+
* a plugin, supplies the subject its diagnostics read with; every other caller is named by the
|
|
264
|
+
* declaration itself.
|
|
263
265
|
*/
|
|
264
|
-
export function checkDeclarations(inputs) {
|
|
266
|
+
export function checkDeclarations(inputs, named) {
|
|
265
267
|
for (const input of inputs) {
|
|
266
|
-
checkDeclaration(input);
|
|
268
|
+
checkDeclaration(input, named ?? declaredName(input));
|
|
267
269
|
}
|
|
268
270
|
for (const input of inputs.filter((entry) => hasDefault(entry))) {
|
|
269
271
|
if (input.config.validate === undefined && !holdsRawDefault(input)) {
|
|
270
|
-
const subject = declaredName(input);
|
|
272
|
+
const subject = named ?? declaredName(input);
|
|
271
273
|
throw new DeclarationError(collects(input)
|
|
272
274
|
? `${subject} default must be an array of strings without a schema. Supply a string array default.`
|
|
273
275
|
: `${subject} default must be a string without a schema. Supply a string default.`);
|
|
@@ -366,7 +368,7 @@ export async function validateValues(invocation) {
|
|
|
366
368
|
for (const entry of declarations) {
|
|
367
369
|
const { input } = entry;
|
|
368
370
|
if (input.kind === 'option' && input.config.type === 'boolean') {
|
|
369
|
-
values.set(input, supplied.options
|
|
371
|
+
values.set(input, booleanValue(supplied.options, input.name, input.config));
|
|
370
372
|
}
|
|
371
373
|
else {
|
|
372
374
|
const collected = collects(input);
|