UNPKG

7.65 kBPlain TextView Raw
1import execa from 'execa';
2import mergeWith from 'lodash/mergeWith';
3import { PrimitiveType } from '@boost/args';
4import { Blueprint, isObject, optimal, Path, Predicates, predicates, toArray } from '@boost/common';
5import { ConcurrentEvent, Event } from '@boost/event';
6import { Plugin } from '@boost/plugin';
7import {
8 STRATEGY_BUFFER,
9 STRATEGY_COPY,
10 STRATEGY_CREATE,
11 STRATEGY_NATIVE,
12 STRATEGY_NONE,
13 STRATEGY_PIPE,
14 STRATEGY_REFERENCE,
15 STRATEGY_STREAM,
16 STRATEGY_TEMPLATE,
17} from './constants';
18import { ConfigContext } from './contexts/ConfigContext';
19import { DriverContext } from './contexts/DriverContext';
20import { isClassInstance } from './helpers/isClassInstance';
21import {
22 Argv,
23 BeemoTool,
24 ConfigObject,
25 Driverable,
26 DriverCommandConfig,
27 DriverCommandRegistration,
28 DriverCommandRunner,
29 DriverConfigStrategy,
30 DriverMetadata,
31 DriverOptions,
32 DriverOutput,
33 DriverOutputStrategy,
34 Execution,
35} from './types';
36
37export abstract class Driver<
38 Config extends object = {},
39 Options extends DriverOptions = DriverOptions,
40 >
41 extends Plugin<BeemoTool, Options>
42 implements Driverable
43{
44 // eslint-disable-next-line @typescript-eslint/no-explicit-any
45 commands: DriverCommandRegistration<any, any>[] = [];
46
47 // Set after instantiation
48 config!: Config;
49
50 // Set after instantiation
51 metadata!: DriverMetadata;
52
53 // Set within a life-cycle
54 tool!: BeemoTool;
55
56 output: DriverOutput = {
57 stderr: '',
58 stdout: '',
59 };
60
61 readonly onLoadProviderConfig = new Event<[ConfigContext, Path, Config]>('load-provider-config');
62
63 readonly onLoadConsumerConfig = new Event<[ConfigContext, Config]>('load-consumer-config');
64
65 readonly onMergeConfig = new Event<[ConfigContext, Config]>('merge-config');
66
67 readonly onCreateConfigFile = new Event<[ConfigContext, Path, Config]>('create-config-file');
68
69 readonly onCopyConfigFile = new Event<[ConfigContext, Path, Config]>('copy-config-file');
70
71 readonly onReferenceConfigFile = new Event<[ConfigContext, Path, Config]>(
72 'reference-config-file',
73 );
74
75 readonly onTemplateConfigFile = new Event<[ConfigContext, Path, ConfigObject | string]>(
76 'template-config-file',
77 );
78
79 readonly onDeleteConfigFile = new Event<[ConfigContext, Path]>('delete-config-file');
80
81 readonly onBeforeExecute = new ConcurrentEvent<[DriverContext, Argv]>('before-execute');
82
83 readonly onAfterExecute = new ConcurrentEvent<[DriverContext, unknown]>('after-execute');
84
85 readonly onFailedExecute = new ConcurrentEvent<[DriverContext, Error]>('failed-execute');
86
87 static validate(driver: Driver) {
88 const name = (isClassInstance(driver) && driver.constructor.name) || 'Driver';
89
90 if (!isObject(driver.options)) {
91 throw new Error(`\`${name}\` requires an options object.`);
92 }
93 }
94
95 blueprint({ array, object, string, bool }: Predicates): Blueprint<DriverOptions> {
96 return {
97 args: array(string()),
98 configStrategy: string(STRATEGY_NATIVE).oneOf<DriverConfigStrategy>([
99 STRATEGY_NATIVE,
100 STRATEGY_CREATE,
101 STRATEGY_REFERENCE,
102 STRATEGY_TEMPLATE,
103 STRATEGY_COPY,
104 STRATEGY_NONE,
105 ]),
106 dependencies: array(string()),
107 env: object(string()),
108 expandGlobs: bool(true),
109 outputStrategy: string(STRATEGY_BUFFER).oneOf<DriverOutputStrategy>([
110 STRATEGY_BUFFER,
111 STRATEGY_PIPE,
112 STRATEGY_STREAM,
113 STRATEGY_NONE,
114 ]),
115 template: string(),
116 };
117 }
118
119 bootstrap() {}
120
121 override startup(tool: BeemoTool) {
122 this.tool = tool;
123 this.bootstrap();
124 }
125
126 /**
127 * Special case for merging arrays.
128 */
129 doMerge(prevValue: unknown, nextValue: unknown): unknown {
130 if (Array.isArray(prevValue) && Array.isArray(nextValue)) {
131 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
132 return [...new Set([...prevValue, ...nextValue])] as unknown;
133 }
134
135 return undefined;
136 }
137
138 /**
139 * Extract the error message when the driver fails to execute.
140 */
141 extractErrorMessage(error: { message: string }): string {
142 return error.message.split('\n', 1)[0] || '';
143 }
144
145 /**
146 * Format the configuration file before it's written.
147 */
148 formatConfig(data: Config): string {
149 const content = JSON.stringify(data, null, 2);
150
151 if (this.metadata.configName.endsWith('.js')) {
152 return `module.exports = ${content};`;
153 }
154
155 return content;
156 }
157
158 /**
159 * Return the module name without the Beemo namespace.
160 */
161 getName(): string {
162 return this.name.split('-').pop()!;
163 }
164
165 /**
166 * Return a list of user defined arguments.
167 */
168 getArgs(): Argv {
169 return toArray(this.options.args);
170 }
171
172 /**
173 * Return a list of dependent drivers.
174 */
175 getDependencies(): string[] {
176 return [
177 // Always required; configured by the driver
178 ...this.metadata.dependencies,
179 // Custom; configured by the consumer
180 ...toArray(this.options.dependencies),
181 ];
182 }
183
184 /**
185 * Either return the tool override strategy, or the per-driver strategy.
186 */
187 getOutputStrategy(): DriverOutputStrategy {
188 return (this.tool.config.execute.output || this.options.outputStrategy) ?? STRATEGY_BUFFER;
189 }
190
191 /**
192 * Return a list of supported CLI options.
193 */
194 getSupportedOptions(): string[] {
195 return [];
196 }
197
198 /**
199 * Extract the current version of the installed driver via its binary.
200 */
201 getVersion(): string {
202 const { bin, versionOption } = this.metadata;
203 const version = (execa.sync(bin, [versionOption], { preferLocal: true })?.stdout || '').trim();
204 const match = version.match(/(\d+)\.(\d+)\.(\d+)/u);
205
206 return match ? match[0] : '0.0.0';
207 }
208
209 /**
210 * Merge multiple configuration objects.
211 */
212 mergeConfig(prev: Config, next: Config): Config {
213 return mergeWith(prev, next, this.doMerge);
214 }
215
216 /**
217 * Handle command failures according to this driver.
218 */
219 processFailure(error: Execution) {
220 const { stderr = '', stdout = '' } = error;
221 const out = (stderr || stdout).trim();
222
223 if (out) {
224 this.setOutput('stderr', out);
225 }
226 }
227
228 /**
229 * Handle successful commands according to this driver.
230 */
231 processSuccess(response: Execution) {
232 const { stderr = '', stdout = '' } = response;
233
234 this.setOutput('stderr', stderr.trim());
235 this.setOutput('stdout', stdout.trim());
236 }
237
238 /**
239 * Register a sub-command within the CLI.
240 */
241 registerCommand<O extends object, P extends PrimitiveType[]>(
242 path: string,
243 config: DriverCommandConfig<O, P>,
244 runner: DriverCommandRunner<O, P>,
245 ) {
246 this.commands.push({ config, path, runner });
247
248 return this;
249 }
250
251 /**
252 * Set metadata about the binary/executable in which this driver wraps.
253 */
254 setMetadata(metadata: Partial<DriverMetadata>): this {
255 const { array, bool, string, object, shape } = predicates;
256
257 this.metadata = optimal(
258 metadata,
259 {
260 bin: string()
261 .match(/^[a-z]{1}[a-zA-Z0-9-]+$/u)
262 .required(),
263 commandOptions: object(
264 shape({
265 description: string().required(),
266 type: string().oneOf<'boolean' | 'number' | 'string'>(['string', 'number', 'boolean']),
267 }),
268 ),
269 configName: string().required(),
270 configOption: string('--config'),
271 configStrategy: string(STRATEGY_CREATE).oneOf([
272 STRATEGY_CREATE,
273 STRATEGY_REFERENCE,
274 STRATEGY_COPY,
275 ]),
276 dependencies: array(string()),
277 description: string(),
278 filterOptions: bool(true),
279 helpOption: string('--help'),
280 title: string().required(),
281 useConfigOption: bool(),
282 versionOption: string('--version'),
283 watchOptions: array(string()),
284 workspaceStrategy: string(STRATEGY_REFERENCE).oneOf([STRATEGY_REFERENCE, STRATEGY_COPY]),
285 },
286 {
287 name: this.constructor.name,
288 },
289 );
290
291 return this;
292 }
293
294 /**
295 * Store the raw output of the driver's execution.
296 */
297 setOutput(type: keyof DriverOutput, value: string): this {
298 this.output[type] = value.trim();
299
300 return this;
301 }
302}