1 | import debugFactory from 'debug';
|
2 | import {sync as glob} from 'glob';
|
3 | import {readFile, writeFile} from 'mz/fs';
|
4 | import {dirname, resolve} from 'path';
|
5 | import {load} from './config';
|
6 | import {
|
7 | findProjectRoot,
|
8 | isAlleRepo,
|
9 | read as readRootPackage,
|
10 | write as writeRootPackage,
|
11 | } from './project';
|
12 | import {spawn} from './spawn';
|
13 | import {sortObject} from './util';
|
14 | import {select} from './version';
|
15 |
|
16 | const debug = debugFactory('clark:lib:packages');
|
17 |
|
18 | const pathsByPackage = new Map();
|
19 | const packagesByPath = new Map();
|
20 | let initialized = false;
|
21 |
|
22 | interface EnvObject {
|
23 | [key: string]: string;
|
24 | }
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 | export 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 |
|
73 |
|
74 |
|
75 |
|
76 |
|
77 |
|
78 |
|
79 |
|
80 | export 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 |
|
104 |
|
105 |
|
106 |
|
107 | function 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 | */
|
122 | export 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 |
|
139 | export 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 | */
|
153 | export 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 | */
|
167 | export 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 | */
|
190 | export 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 |
|
266 | export 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 | */
|
279 | export 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 |
|
313 |
|
314 | export interface MaybeSpecifiesPackageName {
|
315 | packageName?: string | string[] | false;
|
316 | }
|
317 |
|
318 |
|
319 |
|
320 |
|
321 | export interface SpecifiesPackageName {
|
322 | packageName: string | string[];
|
323 | }
|
324 |
|
325 |
|
326 |
|
327 |
|
328 | export interface DoesNotSpecifyPackageName {}
|
329 |
|
330 |
|
331 |
|
332 |
|
333 | async 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 |
|
346 |
|
347 |
|
348 | export async function isPackage(packageName: string): Promise<boolean> {
|
349 | await init();
|
350 | return pathsByPackage.has(packageName);
|
351 | }
|
352 |
|
353 |
|
354 |
|
355 |
|
356 |
|
357 | export async function isPackagePath(dir: string): Promise<boolean> {
|
358 | await init();
|
359 | return packagesByPath.has(dir);
|
360 | }
|
361 |
|
362 |
|
363 |
|
364 |
|
365 |
|
366 |
|
367 |
|
368 | export async function list(): Promise<string[]> {
|
369 | await init();
|
370 | return [...pathsByPackage.keys()];
|
371 | }
|
372 |
|
373 |
|
374 |
|
375 |
|
376 |
|
377 |
|
378 | async function listPackagesInGlob(pattern: string): Promise<void> {
|
379 | debug(`Listing packages in "${pattern}"`);
|
380 |
|
381 |
|
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 |
|
406 |
|
407 | export async function listPaths(): Promise<string[]> {
|
408 | await init();
|
409 | return [...packagesByPath.keys()];
|
410 | }
|
411 |
|
412 |
|
413 |
|
414 |
|
415 |
|
416 | export 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 |
|
428 |
|
429 |
|
430 |
|
431 | export 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 |