1 |
|
2 |
|
3 | import { createRequire } from 'module';
|
4 | import fs from 'fs-extra';
|
5 | import {
|
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';
|
18 | import { createDebugger, Debugger } from '@boost/debug';
|
19 | import { Event } from '@boost/event';
|
20 | import { color } from '@boost/internal';
|
21 | import { Writable } from '@boost/log';
|
22 | import { WaterfallPipeline } from '@boost/pipeline';
|
23 | import { Registry } from '@boost/plugin';
|
24 | import { createTranslator, Translator } from '@boost/translate';
|
25 | import { Config } from './Config';
|
26 | import { KEBAB_PATTERN } from './constants';
|
27 | import { ConfigContext } from './contexts/ConfigContext';
|
28 | import { Context } from './contexts/Context';
|
29 | import { DriverContext } from './contexts/DriverContext';
|
30 | import { ScaffoldContext } from './contexts/ScaffoldContext';
|
31 | import { ScriptContext } from './contexts/ScriptContext';
|
32 | import { Driver } from './Driver';
|
33 | import { CleanupConfigsRoutine } from './routines/CleanupConfigsRoutine';
|
34 | import { ResolveConfigsRoutine } from './routines/ResolveConfigsRoutine';
|
35 | import { RunDriverRoutine } from './routines/RunDriverRoutine';
|
36 | import { RunScriptRoutine } from './routines/RunScriptRoutine';
|
37 | import { ScaffoldRoutine } from './routines/ScaffoldRoutine';
|
38 | import { Script } from './Script';
|
39 | import { Argv, BootstrapFile, ConfigFile } from './types';
|
40 |
|
41 | export interface ToolOptions {
|
42 | argv: Argv;
|
43 | cwd?: PortablePath;
|
44 | projectName?: string;
|
45 | resourcePaths?: string[];
|
46 | }
|
47 |
|
48 | export 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 |
|
124 | const { config } = await this.configManager.loadConfigFromRoot(this.cwd);
|
125 |
|
126 | this.config = config;
|
127 | this.package = this.project.getPackage();
|
128 |
|
129 |
|
130 | await this.driverRegistry.loadMany(config.drivers, { tool: this });
|
131 |
|
132 |
|
133 | await this.scriptRegistry.loadMany(config.scripts, { tool: this });
|
134 | }
|
135 |
|
136 | |
137 |
|
138 |
|
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 |
|
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 |
|
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 |
|
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 |
|
225 | if (Array.isArray(context.configPaths)) {
|
226 | context.configPaths.forEach((config) => {
|
227 | fs.removeSync(config.path.path());
|
228 | });
|
229 | }
|
230 | }
|
231 |
|
232 | |
233 |
|
234 |
|
235 | createConfigurePipeline(args: ConfigContext['args'], driverNames: string[] = []) {
|
236 | const context = this.prepareContext(new ConfigContext(args));
|
237 |
|
238 |
|
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 |
|
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 |
|
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 |
|
302 | .skip(!this.config.configure.cleanup),
|
303 | );
|
304 | }
|
305 |
|
306 | |
307 |
|
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 |
|
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 |
|
351 |
|
352 |
|
353 |
|
354 | @Bind()
|
355 | resolveForPnP(id: string): string {
|
356 |
|
357 | const rootRequire = createRequire(this.cwd.append('package.json').path());
|
358 |
|
359 |
|
360 | try {
|
361 | return rootRequire.resolve(id);
|
362 | } catch {
|
363 |
|
364 | }
|
365 |
|
366 |
|
367 |
|
368 | const moduleRequire = createRequire(rootRequire.resolve(`${this.config.module}/package.json`));
|
369 |
|
370 | return moduleRequire.resolve(id);
|
371 | }
|
372 |
|
373 | |
374 |
|
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 |
|
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 |
|
403 | this.context = context;
|
404 |
|
405 | return context;
|
406 | }
|
407 | }
|