UNPKG

12.5 kBPlain TextView Raw
1import debugFactory from 'debug';
2import {sync as glob} from 'glob';
3import {readFile, writeFile} from 'mz/fs';
4import {dirname, resolve} from 'path';
5import {load} from './config';
6import {
7 findProjectRoot,
8 isAlleRepo,
9 read as readRootPackage,
10 write as writeRootPackage,
11} from './project';
12import {spawn} from './spawn';
13import {sortObject} from './util';
14import {select} from './version';
15
16const debug = debugFactory('clark:lib:packages');
17
18const pathsByPackage = new Map();
19const packagesByPath = new Map();
20let initialized = false;
21
22interface EnvObject {
23 [key: string]: string;
24}
25
26/**
27 * Executes the specified command against the specified package
28 * @param cmd
29 * @param packageName
30 */
31export async function exec(cmd: string, packageName: string): Promise<void> {
32 if (!await isPackage(packageName)) {
33 throw new Error(`"${packageName}" does not appear to identify a package`);
34 }
35
36 debug(`running command "${cmd}" in directory for package "${packageName}"`);
37 const bin = 'bash';
38 const args = ['-c', cmd];
39 const {PATH, ...env} = process.env;
40 const clarkEnv = {
41 CLARK_PACKAGE_ABS_PATH: resolve(
42 await findProjectRoot(),
43 await getPackagePath(packageName),
44 ),
45 CLARK_PACKAGE_NAME: packageName,
46 CLARK_PACKAGE_REL_PATH: await getPackagePath(packageName),
47 CLARK_ROOT_PATH: await findProjectRoot(),
48 ...filterEnv(env),
49 };
50
51 try {
52 const result = await spawn(bin, args, {
53 cwd: resolve(await findProjectRoot(), await getPackagePath(packageName)),
54 env: {
55 ...clarkEnv,
56 PATH: `${PATH}:${resolve(
57 await findProjectRoot(),
58 'node_modules',
59 '.bin',
60 )}`,
61 },
62 });
63 debug(`ran command "${cmd}" in directory for package "${packageName}"`);
64 return result;
65 } catch (err) {
66 debug(`command "${cmd}" failed for package "${packageName}"`);
67 throw err;
68 }
69}
70
71/**
72 * Executes the specified npm script in the specified package. If the package
73 * does not have a definition for the script and fallbackScript is provided,
74 * then fallbackScript will be executed directly (i.e., run as a bash command,
75 * not as an npm script).
76 * @param scriptName
77 * @param packageName
78 * @param fallbackScript
79 */
80export async function execScript(
81 scriptName: string,
82 packageName: string,
83 fallbackScript?: string,
84): Promise<void> {
85 debug(`Running script "${scriptName}" in "${packageName}"`);
86 if (await hasScript(packageName, scriptName)) {
87 debug('Using override script');
88 return await exec(`npm run --silent ${scriptName}`, packageName);
89 }
90
91 if (!fallbackScript) {
92 debug(
93 `Neither override nor fallback script defined for script "${scriptName}"`,
94 );
95 throw new Error(`${packageName} does not implement ${scriptName}`);
96 }
97
98 debug(`Falling back to run "${scriptName}" in "${packageName}"`);
99 return await exec(fallbackScript, packageName);
100}
101
102/**
103 * Removes any `CLARK_` prefixed variables from env before passing them to
104 * `spawn()`.
105 * @param env
106 */
107function filterEnv(env: object): object {
108 return Object.entries(env).reduce<EnvObject>((acc, [key, value]) => {
109 if (!key.startsWith('CLARK_')) {
110 acc[key] = value;
111 }
112
113 return acc;
114 }, {});
115}
116
117/**
118 * Higher-level version of that "does the right thing" whether packageName is
119 * provided or not.
120 * @param options
121 */
122export async function gather(options: gather.Options): Promise<string[]> {
123 options = await infer(options);
124 const {packageName} = options;
125 if (packageName) {
126 if (Array.isArray(packageName)) {
127 debug(`User specified ${packageName.length} packages`);
128 return packageName.sort();
129 } else {
130 debug('User specified a single package');
131 return [packageName];
132 }
133 }
134
135 debug('User did not specify an packages; listing all packages');
136 return (await list()).sort();
137}
138
139export namespace gather {
140 /**
141 * Options for gather()
142 */
143 export interface Options {
144 packageName?: string | string[];
145 }
146}
147
148/**
149 * Returns the relative path from the monorepo root to the directory containing
150 * the specified package's package.json.
151 * @param packageName
152 */
153export async function getPackagePath(packageName: string): Promise<string> {
154 await init();
155 if (!await isPackage(packageName)) {
156 throw new Error(`"${packageName}" does not appear to identify a package`);
157 }
158 return pathsByPackage.get(packageName);
159}
160
161/**
162 * Indicates if the specified package has an implementation of the specified
163 * npm script
164 * @param packageName
165 * @param scriptName
166 */
167export async function hasScript(
168 packageName: string,
169 scriptName: string,
170): Promise<boolean> {
171 debug(`Checking if "${packageName}" has a "${scriptName}" script`);
172 const pkg = await read(packageName);
173 const has = !!(pkg.scripts && pkg.scripts[scriptName]);
174 debug(
175 `"${packageName}" ${
176 has ? ' has ' : ' does not have '
177 } a script named "${scriptName}"`,
178 );
179 return has;
180}
181
182/**
183 * Moves dependencies and dev dependencies from the specified package's
184 * package.json to the dependencies section of the root package.json. Note that
185 * dependencies and devDepenencies are combined because the distinction loses
186 * meaning in a monorepo (arguably, they should all be devDependencies, but
187 * that's not where `npm install` defaults).
188 * @param packageName
189 */
190export async function hoist(
191 packageName: string,
192 options: hoist.Options = {risky: false},
193): Promise<void> {
194 debug(`Reading deps from "${packageName}"`);
195 const pkg = await read(packageName);
196 const rootPkg = await readRootPackage();
197
198 rootPkg.dependencies = rootPkg.dependencies || {};
199
200 const deps = {
201 ...pkg.dependencies,
202 ...pkg.devDependencies,
203 };
204
205 for (const [depName, depVersion] of Object.entries(deps)) {
206 const rootVersion = rootPkg.dependencies[depName];
207
208 if (!rootVersion) {
209 debug(
210 `Root package does not yet have a version of "${depName}", defaulting to "${packageName}"'s version of "${depVersion}"`,
211 );
212 rootPkg.dependencies[depName] = depVersion;
213 } else if (options.risky) {
214 debug(
215 `Checking if root "${depName}@${rootVersion}" is loosely compatible with "${depName}@${depVersion}"`,
216 );
217
218 try {
219 const toUseVersion = select(rootVersion, depVersion as string);
220 rootPkg.dependencies[depName] = toUseVersion;
221 debug(
222 `"root ${depName}@${rootVersion}" is loosely compatible with "${packageName}" "${depName}@${depVersion}"`,
223 );
224 } catch (err) {
225 debug(
226 `"root ${depName}@${rootVersion}" is not loosely compatible with "${packageName}" "${depName}@${depVersion}"`,
227 );
228 throw new Error(
229 `Cowardly refusing to overwrite "${depName}@${rootVersion}" for "${depName}@${depVersion}" from "${packageName}"`,
230 );
231 }
232 } else {
233 debug(
234 `Checking if root "${depName}@${rootVersion}" is strictly compatible with "${depName}@${depVersion}"`,
235 );
236 if (rootVersion !== depVersion) {
237 debug(
238 `"root ${depName}@${rootVersion}" is not strictly compatible with "${packageName}" "${depName}@${depVersion}"`,
239 );
240 throw new Error(
241 `Cowardly refusing to overwrite "${depName}@${rootVersion}" for "${depName}@${depVersion}" from "${packageName}"`,
242 );
243 }
244 debug(
245 `"root ${depName}@${rootVersion}" is strictly compatible with "${packageName}" "${depName}@${depVersion}"`,
246 );
247 rootPkg.dependencies[depName] = depVersion;
248 }
249 }
250
251 delete pkg.dependencies;
252 delete pkg.devDependencies;
253
254 if (!isAlleRepo(await listPaths())) {
255 rootPkg.dependencies[packageName] = `file:./${await getPackagePath(
256 packageName,
257 )}`;
258 }
259
260 rootPkg.dependencies = sortObject(rootPkg.dependencies);
261
262 await write(packageName, pkg);
263 await writeRootPackage(rootPkg);
264}
265
266export namespace hoist {
267 /**
268 * Options for the hoist function
269 */
270 export interface Options {
271 risky?: boolean;
272 }
273}
274
275/**
276 * Attempts to infer the intended packageName from the current directory
277 * @param options
278 */
279export async function infer(
280 options: MaybeSpecifiesPackageName,
281): Promise<SpecifiesPackageName | DoesNotSpecifyPackageName> {
282 debug('Inferring packageName if necessary');
283 if (options.packageName) {
284 debug('packageName was specified, not inferring');
285 return options;
286 }
287
288 if (options.packageName === false) {
289 debug('packageName inferrence has been disabled');
290 return options;
291 }
292
293 debug('packageName was not specified');
294 await init();
295
296 const relCwd = process
297 .cwd()
298 .replace(await findProjectRoot(), '')
299 .replace(/^\//, '');
300
301 if (await isPackagePath(relCwd)) {
302 debug('Inferred packageName');
303 options.packageName = packagesByPath.get(relCwd);
304 } else {
305 debug('Could not infer packageName');
306 }
307
308 return options;
309}
310
311/**
312 * Describes an Options object that might have a packageName property
313 */
314export interface MaybeSpecifiesPackageName {
315 packageName?: string | string[] | false;
316}
317
318/**
319 * Describes an Options object that has a packageName property
320 */
321export interface SpecifiesPackageName {
322 packageName: string | string[];
323}
324
325/**
326 * Describes an Options object that does not have a packageName property
327 */
328export interface DoesNotSpecifyPackageName {}
329
330/**
331 * Helper
332 */
333async function init(): Promise<void> {
334 if (!initialized) {
335 initialized = true;
336 debug('Globbing for packages');
337 const patterns = (await load()).include || [];
338 for (const pattern of Array.isArray(patterns) ? patterns : [patterns]) {
339 await listPackagesInGlob(pattern);
340 }
341 }
342}
343
344/**
345 * Indicates if the given packageName identifies a package in the monorpeo
346 * @param packageName
347 */
348export async function isPackage(packageName: string): Promise<boolean> {
349 await init();
350 return pathsByPackage.has(packageName);
351}
352
353/**
354 * Indicates if the given directory contains a package
355 * @param dir
356 */
357export async function isPackagePath(dir: string): Promise<boolean> {
358 await init();
359 return packagesByPath.has(dir);
360}
361
362/**
363 * Returns the names of the packages defined in the project
364 *
365 * Note: This packages are the package.json names, not necessarily the directory
366 * paths containing the packages.
367 */
368export async function list(): Promise<string[]> {
369 await init();
370 return [...pathsByPackage.keys()];
371}
372
373/**
374 * Loads all packages found in a particular glob pattern
375 *
376 * @param pattern
377 */
378async function listPackagesInGlob(pattern: string): Promise<void> {
379 debug(`Listing packages in "${pattern}"`);
380 // I'm a little concerned just tacking package.json on the end could break
381 // certain glob patterns, but I don't have any proof to back that up.
382 const paths = glob(`${pattern}/package.json`, {cwd: await findProjectRoot()});
383 debug(`Found ${paths.length} directories in "${pattern}"`);
384
385 for (const packagePath of paths) {
386 debug(`Getting name of package at "${packagePath}" from package.json`);
387 const dir = dirname(packagePath);
388 const pkg = JSON.parse(
389 await readFile(resolve(await findProjectRoot(), packagePath), 'utf-8'),
390 );
391 debug(`Found "${pkg.name}" in "${dir}"`);
392 if (pathsByPackage.has(pkg.name) && pathsByPackage.get(pkg.name) !== dir) {
393 throw new Error(
394 `Package names must be unique. "${
395 pkg.name
396 } found in "${dir}" and "${pathsByPackage.get(pkg.name)}"`,
397 );
398 }
399 pathsByPackage.set(pkg.name, dir);
400 packagesByPath.set(dir, pkg.name);
401 }
402}
403
404/**
405 * Returns the directory paths of each package.json
406 */
407export async function listPaths(): Promise<string[]> {
408 await init();
409 return [...packagesByPath.keys()];
410}
411
412/**
413 * Reads a package.json from the monorepo
414 * @param packageName
415 */
416export async function read(packageName: string) {
417 const packagePath = resolve(
418 await findProjectRoot(),
419 await getPackagePath(packageName),
420 'package.json',
421 );
422 debug(`Reading package "${packageName}" at path "${packagePath}"`);
423 return JSON.parse(await readFile(packagePath, 'utf-8'));
424}
425
426/**
427 * Writes a new package.json to the appropriate package
428 * @param packageName
429 * @param pkg
430 */
431export async function write(packageName: string, pkg: object) {
432 const packagePath = resolve(
433 await findProjectRoot(),
434 await getPackagePath(packageName),
435 'package.json',
436 );
437 debug(`Writing package "${packageName}" at path "${packagePath}"`);
438
439 return await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
440}
441
\No newline at end of file