UNPKG

11 kBPlain TextView Raw
1/* eslint-disable @typescript-eslint/member-ordering */
2
3import { createRequire } from 'module';
4import fs from 'fs-extra';
5import {
6 Bind,
7 Blueprint,
8 Contract,
9 Memoize,
10 PackageStructure,
11 Path,
12 PathResolver,
13 PortablePath,
14 Predicates,
15 Project,
16 requireModule,
17} from '@boost/common';
18import { createDebugger, Debugger } from '@boost/debug';
19import { Event } from '@boost/event';
20import { color } from '@boost/internal';
21import { Writable } from '@boost/log';
22import { WaterfallPipeline } from '@boost/pipeline';
23import { Registry } from '@boost/plugin';
24import { createTranslator, Translator } from '@boost/translate';
25import { Config } from './Config';
26import { KEBAB_PATTERN } from './constants';
27import { ConfigContext } from './contexts/ConfigContext';
28import { Context } from './contexts/Context';
29import { DriverContext } from './contexts/DriverContext';
30import { ScaffoldContext } from './contexts/ScaffoldContext';
31import { ScriptContext } from './contexts/ScriptContext';
32import { Driver } from './Driver';
33import { CleanupConfigsRoutine } from './routines/CleanupConfigsRoutine';
34import { ResolveConfigsRoutine } from './routines/ResolveConfigsRoutine';
35import { RunDriverRoutine } from './routines/RunDriverRoutine';
36import { RunScriptRoutine } from './routines/RunScriptRoutine';
37import { ScaffoldRoutine } from './routines/ScaffoldRoutine';
38import { Script } from './Script';
39import { Argv, BootstrapFile, ConfigFile } from './types';
40
41export interface ToolOptions {
42 argv: Argv;
43 cwd?: PortablePath;
44 projectName?: string;
45 resourcePaths?: string[];
46}
47
48export class Tool extends Contract<ToolOptions> {
49 config!: ConfigFile;
50
51 context?: Context;
52
53 errStream?: Writable;
54
55 outStream?: Writable;
56
57 package!: PackageStructure;
58
59 readonly argv: Argv;
60
61 readonly configManager: Config;
62
63 readonly cwd: Path;
64
65 readonly debug: Debugger;
66
67 readonly driverRegistry: Registry<Driver>;
68
69 readonly msg: Translator;
70
71 readonly project: Project;
72
73 readonly onResolveDependencies = new Event<[ConfigContext, Driver[]]>('resolve-dependencies');
74
75 readonly onRunCreateConfig = new Event<[ConfigContext, string[]]>('run-create-config');
76
77 readonly onRunDriver = new Event<[DriverContext, Driver]>('run-driver');
78
79 readonly onRunScaffold = new Event<[ScaffoldContext, string, string, string?]>('run-scaffold');
80
81 readonly onRunScript = new Event<[ScriptContext]>('run-script');
82
83 readonly scriptRegistry: Registry<Script>;
84
85 constructor(options: ToolOptions) {
86 super(options);
87
88 this.argv = this.options.argv;
89 this.cwd = Path.create(this.options.cwd);
90
91 this.debug = createDebugger('core');
92 this.debug('Using beemo v%s', (require('../package.json') as PackageStructure).version);
93
94 this.msg = createTranslator(
95 ['app', 'common', 'errors'],
96 [new Path(__dirname, '../resources'), ...this.options.resourcePaths],
97 );
98
99 this.driverRegistry = new Registry('beemo', 'driver', {
100 resolver: this.resolveForPnP,
101 validate: Driver.validate,
102 });
103
104 this.scriptRegistry = new Registry('beemo', 'script', {
105 resolver: this.resolveForPnP,
106 validate: Script.validate,
107 });
108
109 this.project = new Project(this.cwd);
110 this.configManager = new Config(this.options.projectName, this.resolveForPnP);
111 }
112
113 blueprint({ array, instance, string, union }: Predicates): Blueprint<ToolOptions> {
114 return {
115 argv: array(string()),
116 cwd: union([instance(Path).notNullable(), string().notEmpty()], process.cwd()),
117 projectName: string('beemo').camelCase().notEmpty(),
118 resourcePaths: array(string().notEmpty()),
119 };
120 }
121
122 async bootstrap() {
123 // Load config
124 const { config } = await this.configManager.loadConfigFromRoot(this.cwd);
125
126 this.config = config;
127 this.package = this.project.getPackage();
128
129 // Load drivers
130 await this.driverRegistry.loadMany(config.drivers, { tool: this });
131
132 // Load scripts
133 await this.scriptRegistry.loadMany(config.scripts, { tool: this });
134 }
135
136 /**
137 * If the config module has an index that exports a function,
138 * execute it with the current tool instance.
139 */
140 async bootstrapConfigModule() {
141 this.debug('Bootstrapping configuration module');
142
143 const { module } = this.config;
144 let bootstrapModule: BootstrapFile | null = null;
145
146 try {
147 const root = this.getConfigModuleRoot();
148 const resolver = new PathResolver()
149 .lookupFilePath('index.ts', root)
150 .lookupFilePath('index.js', root)
151 .lookupFilePath('src/index.ts', root)
152 .lookupFilePath('lib/index.js', root);
153
154 if (module !== '@local') {
155 resolver.lookupNodeModule(module);
156 }
157
158 const { resolvedPath } = resolver.resolve();
159
160 bootstrapModule = requireModule(resolvedPath);
161 } catch {
162 this.debug('No bootstrap file detected, aborting bootstrap');
163
164 return this;
165 }
166
167 const bootstrap = bootstrapModule?.bootstrap ?? bootstrapModule?.default ?? bootstrapModule;
168 const isFunction = typeof bootstrap === 'function';
169
170 this.debug.invariant(isFunction, 'Executing bootstrap function', 'Found', 'Not found');
171
172 if (bootstrap && isFunction) {
173 await bootstrap(this);
174 }
175
176 return this;
177 }
178
179 /**
180 * Validate the configuration module and return an absolute path to its root folder.
181 */
182 @Memoize()
183 getConfigModuleRoot(): Path {
184 const { module } = this.config;
185
186 this.debug('Locating configuration module root');
187
188 if (!module) {
189 throw new Error(this.msg('errors:moduleConfigMissing'));
190 }
191
192 // Allow for local development
193 if (module === '@local') {
194 this.debug('Using %s configuration module', color.moduleName('@local'));
195
196 return new Path(this.options.cwd);
197 }
198
199 // Reference a node module
200 let rootPath: Path;
201
202 try {
203 rootPath = Path.resolve(this.resolveForPnP(`${module}/package.json`)).parent();
204 } catch {
205 throw new Error(this.msg('errors:moduleMissing', { module }));
206 }
207
208 this.debug('Found configuration module root at path: %s', color.filePath(rootPath));
209
210 return rootPath;
211 }
212
213 /**
214 * Delete config files if a process fails.
215 */
216 @Bind()
217 cleanupOnFailure(error?: Error) {
218 const { context } = this;
219
220 if (!error || !context) {
221 return;
222 }
223
224 // Must not be async!
225 if (Array.isArray(context.configPaths)) {
226 context.configPaths.forEach((config) => {
227 fs.removeSync(config.path.path());
228 });
229 }
230 }
231
232 /**
233 * Create a pipeline to run the create config files flow.
234 */
235 createConfigurePipeline(args: ConfigContext['args'], driverNames: string[] = []) {
236 const context = this.prepareContext(new ConfigContext(args));
237
238 // Create for all enabled drivers
239 if (driverNames.length === 0) {
240 this.driverRegistry.getAll().forEach((driver) => {
241 context.addDriverDependency(driver);
242 driverNames.push(driver.getName());
243 });
244
245 this.debug('Running with all drivers');
246
247 // Create for one or many driver
248 } else {
249 driverNames.forEach((driverName) => {
250 context.addDriverDependency(this.driverRegistry.get(driverName));
251 });
252
253 this.debug('Running with %s driver(s)', driverNames.join(', '));
254 }
255
256 this.onRunCreateConfig.emit([context, driverNames]);
257
258 return new WaterfallPipeline(context).pipe(
259 new ResolveConfigsRoutine('config', this.msg('app:configGenerate'), {
260 tool: this,
261 }),
262 );
263 }
264
265 /**
266 * Execute all routines for the chosen driver.
267 */
268 createRunDriverPipeline(
269 args: DriverContext['args'],
270 driverName: string,
271 parallelArgv: Argv[] = [],
272 ) {
273 const driver = this.driverRegistry.get(driverName);
274 const context = this.prepareContext(new DriverContext(args, driver, parallelArgv));
275 const version = driver.getVersion();
276
277 this.onRunDriver.emit([context, driver], driverName);
278
279 this.debug('Running with %s v%s driver', driverName, version);
280
281 return new WaterfallPipeline(context, driverName)
282 .pipe(
283 new ResolveConfigsRoutine('config', this.msg('app:configGenerate'), {
284 tool: this,
285 }),
286 )
287 .pipe(
288 new RunDriverRoutine(
289 'driver',
290 this.msg('app:driverRun', {
291 name: driver.metadata.title,
292 version,
293 }),
294 { tool: this },
295 ),
296 )
297 .pipe(
298 new CleanupConfigsRoutine('cleanup', this.msg('app:cleanup'), {
299 tool: this,
300 })
301 // Only add cleanup routine if we need it
302 .skip(!this.config.configure.cleanup),
303 );
304 }
305
306 /**
307 * Run a script found within the configuration module.
308 */
309 createRunScriptPipeline(args: ScriptContext['args'], scriptName: string) {
310 if (!scriptName || !scriptName.match(KEBAB_PATTERN)) {
311 throw new Error(this.msg('errors:scriptNameInvalidFormat'));
312 }
313
314 const context = this.prepareContext(new ScriptContext(args, scriptName));
315
316 this.onRunScript.emit([context], scriptName);
317
318 this.debug('Running with %s script', context.scriptName);
319
320 return new WaterfallPipeline(context).pipe(
321 new RunScriptRoutine('script', this.msg('app:scriptRun', { name: scriptName }), {
322 tool: this,
323 }),
324 );
325 }
326
327 /**
328 * Create a pipeline to run the scaffolding flow.
329 */
330 createScaffoldPipeline(
331 args: ScaffoldContext['args'],
332 generator: string,
333 action: string,
334 name: string = '',
335 ) {
336 const context = this.prepareContext(new ScaffoldContext(args, generator, action, name));
337
338 this.onRunScaffold.emit([context, generator, action, name]);
339
340 this.debug('Creating scaffold pipeline');
341
342 return new WaterfallPipeline(context).pipe(
343 new ScaffoldRoutine('scaffold', this.msg('app:scaffoldGenerate'), {
344 tool: this,
345 }),
346 );
347 }
348
349 /**
350 * Resolve modules on *behalf* of the configuration module and within the context
351 * of its dependencies. This functionality is necessary to satisfy Yarn PnP and
352 * resolving plugins that aren't listed as direct dependencies.
353 */
354 @Bind()
355 resolveForPnP(id: string): string {
356 // Create a `require` on behalf of the project root
357 const rootRequire = createRequire(this.cwd.append('package.json').path());
358
359 // Attempt to resolve from the root incase dependencies have been defined there
360 try {
361 return rootRequire.resolve(id);
362 } catch {
363 // Ignore
364 }
365
366 // Otherwise, create a `require` on behalf of the configuration module,
367 // which is ALSO resolved from the root (assumes the config module is a dependency there)
368 const moduleRequire = createRequire(rootRequire.resolve(`${this.config.module}/package.json`));
369
370 return moduleRequire.resolve(id);
371 }
372
373 /**
374 * Prepare the context object by setting default values for specific properties.
375 */
376 protected prepareContext<T extends Context>(context: T): T {
377 context.argv = this.argv;
378 context.cwd = this.cwd;
379 context.configModuleRoot = this.getConfigModuleRoot();
380 context.workspaceRoot = this.project.root;
381 context.workspaces = this.project.getWorkspaceGlobs();
382
383 // Make the tool available for all processes
384 const processObject = {
385 context,
386 tool: this,
387 };
388
389 Object.defineProperties(process, {
390 beemo: {
391 configurable: true,
392 enumerable: true,
393 value: processObject,
394 },
395 [this.options.projectName]: {
396 configurable: true,
397 enumerable: true,
398 value: processObject,
399 },
400 });
401
402 // Set the current class to the tool instance
403 this.context = context;
404
405 return context;
406 }
407}