Skip to content

Commit f6ec4e0

Browse files
authored
feat: added support for monorepo workspaces (#233)
1 parent e749c31 commit f6ec4e0

File tree

12 files changed

+414
-25
lines changed

12 files changed

+414
-25
lines changed

index.js

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const fs = require('fs-extra');
44
const opta = require('opta');
55
const parseList = require('safe-parse-list');
66
const { create, load } = require('@npmcli/package-json');
7+
const mapWorkspaces = require('@npmcli/map-workspaces');
78
const { Loggerr } = require('loggerr');
89
const packageName = require('./lib/package-name');
910
const git = require('./lib/git');
@@ -123,6 +124,22 @@ function initOpts () {
123124
}
124125
},
125126

127+
workspaceRoot: {
128+
type: 'string',
129+
flag: {
130+
key: 'workspace-root'
131+
},
132+
prompt: {
133+
message: 'Workspace Root:',
134+
when: (promptInput, defaultWhen, allInput) => {
135+
if (allInput.workspaceRoot !== allInput.cwd) {
136+
return true;
137+
}
138+
return defaultWhen;
139+
}
140+
}
141+
},
142+
126143
type: {
127144
type: 'string',
128145
prompt: {
@@ -255,6 +272,50 @@ async function readPackageJson (options, { log } = {}) {
255272
log.error(e);
256273
}
257274

275+
// Check to see if we are in a monorepo context
276+
if (!pkg.workspaces) {
277+
let rootPkg;
278+
let rootDir = opts.workspaceRoot;
279+
if (!rootDir) {
280+
// Limit to git root if in a checked out repo
281+
const gitRoot = await git.repositoryRoot(opts.cwd);
282+
283+
const [_rootPkg, _rootDir] = await npm.findWorkspaceRoot(opts.cwd, gitRoot);
284+
rootPkg = _rootPkg;
285+
rootDir = _rootDir;
286+
} else {
287+
try {
288+
rootPkg = await npm.readPkg(rootDir);
289+
} catch (e) {
290+
if (e.code !== 'ENOENT') {
291+
throw e;
292+
}
293+
// ignore
294+
}
295+
}
296+
297+
// If we are *not* inside what looks like an existing monorepo setup,
298+
// check what packages may show up and suggest them as defaults
299+
// for the `workspaces` key
300+
if (rootDir === opts.cwd) {
301+
const maybeWorkspaces = await npm.searchForWorkspaces(opts.cwd, pkg);
302+
if (maybeWorkspaces.size) {
303+
pkg.workspaces = [];
304+
for (const wsDir of maybeWorkspaces.values()) {
305+
pkg.workspaces.push(path.relative(opts.cwd, wsDir));
306+
}
307+
}
308+
} else {
309+
// We are in a workspace context, don't ask about
310+
// workspaces and set the workspaceRoot
311+
options.overrides({
312+
workspaces: null,
313+
workspaceRoot: rootDir,
314+
workspaceRootPkg: rootPkg
315+
});
316+
}
317+
}
318+
258319
let author;
259320
if (!pkg || !pkg.author) {
260321
const gitAuthor = await git.author({ cwd: opts.cwd });
@@ -285,7 +346,8 @@ async function readPackageJson (options, { log } = {}) {
285346
repository: repo,
286347
keywords: pkg.keywords,
287348
scripts: pkg.scripts,
288-
license: pkg.license
349+
license: pkg.license,
350+
workspaces: pkg.workspaces
289351
});
290352

291353
return packageInstance.update(pkg);
@@ -364,26 +426,86 @@ module.exports.write = write;
364426
// TODO: look at https://npm.im/json-file-plus for writing
365427
async function write (opts, pkg, { log } = {}) {
366428
const pkgPath = path.resolve(opts.cwd, 'package.json');
429+
430+
// Ensure directory exists
431+
await fs.mkdirp(path.dirname(pkgPath));
432+
367433
// Write package json
368434
log.info(`Writing package.json\n${pkgPath}`);
369435
await pkg.save();
370436

437+
// If we dont have workspaceRootPkg then we are
438+
// already working on the root workspace package.json
439+
// which means we don't need to do anything with updating
440+
// the monorepo
441+
let workspaceRelativePath = null;
442+
if (opts.workspaceRoot && opts.workspaceRootPkg) {
443+
workspaceRelativePath = path.relative(opts.workspaceRoot, opts.cwd);
444+
445+
// Check if this wis already part of the workspace
446+
const ws = await mapWorkspaces({
447+
pkg: opts.workspaceRootPkg,
448+
cwd: opts.workspaceRoot
449+
});
450+
if (Array.from(ws.values()).includes(opts.cwd)) {
451+
log.debug('Workspaces globs already match the new package path, no update necessary');
452+
} else {
453+
log.info('Adding new package to workspace root package.json');
454+
const rootPkg = await load(opts.workspaceRoot);
455+
rootPkg.update({
456+
workspaces: [...(opts.workspaceRootPkg.workspaces || []), workspaceRelativePath]
457+
});
458+
await rootPkg.save();
459+
}
460+
461+
log.info('Running install', {
462+
directory: opts.workspaceRoot || opts.cwd
463+
});
464+
const out = await npm.install(null, {
465+
directory: opts.workspaceRoot || opts.cwd
466+
});
467+
if (out.stderr) {
468+
log.error(out.stderr);
469+
}
470+
log.debug(out.stdout);
471+
}
472+
371473
// Run installs
372474
if (opts.dependencies && opts.dependencies.length) {
373-
log.info('Installing dependencies', opts.dependencies);
374-
await npm.install(opts.dependencies, {
475+
log.info('Installing dependencies', {
476+
save: 'prod',
477+
directory: opts.workspaceRoot || opts.cwd,
478+
exact: !!opts.saveExact,
479+
workspace: workspaceRelativePath
480+
});
481+
const out = await npm.install(opts.dependencies, {
375482
save: 'prod',
376-
directory: opts.cwd,
377-
exact: !!opts.saveExact
483+
directory: opts.workspaceRoot || opts.cwd,
484+
exact: !!opts.saveExact,
485+
workspace: workspaceRelativePath
378486
});
487+
if (out.stderr) {
488+
log.error(out.stderr);
489+
}
490+
log.debug(out.stdout);
379491
}
380492
if (opts.devDependencies && opts.devDependencies.length) {
381-
log.info('Installing dev dependencies', opts.devDependencies);
382-
await npm.install(opts.devDependencies, {
493+
log.info('Installing dev dependencies', {
494+
save: 'dev',
495+
directory: opts.workspaceRoot || opts.cwd,
496+
exact: !!opts.saveExact,
497+
workspace: workspaceRelativePath
498+
});
499+
const out = await npm.install(opts.devDependencies, {
383500
save: 'dev',
384-
directory: opts.cwd,
385-
exact: !!opts.saveExact
501+
directory: opts.workspaceRoot || opts.cwd,
502+
exact: !!opts.saveExact,
503+
workspace: workspaceRelativePath
386504
});
505+
if (out.stderr) {
506+
log.error(out.stderr);
507+
}
508+
log.debug(out.stdout);
387509
}
388510

389511
// Read full package back to return

lib/find-package-json.js

Whitespace-only changes.

lib/git.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,27 @@ module.exports.repository = async function (cwd, pkg) {
6868
return pkg.repository.url;
6969
}
7070
};
71+
72+
module.exports.repositoryRoot = async function repositoryRoot (cwd) {
73+
try {
74+
// Since we call this *before* creating the new pacakge directory in a monorepo,
75+
// we need to find the first real directory above `cwd` because otherwise this
76+
// command will succeede but use the system cwd instead
77+
let _cwd = cwd;
78+
while (_cwd !== '/') {
79+
try {
80+
const p = await fs.realpath(_cwd);
81+
await fs.access(p, fs.constants.W_OK | fs.constants.R_OK);
82+
_cwd = p;
83+
break;
84+
} catch (e) {
85+
_cwd = path.dirname(_cwd);
86+
}
87+
}
88+
89+
const out = await execFile('git', ['rev-parse', '--show-toplevel'], { cwd: _cwd });
90+
return out.stdout.trim();
91+
} catch (e) {
92+
// Ignore errors
93+
}
94+
};

lib/npm.js

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
'use strict';
22
const { promisify } = require('util');
3+
const path = require('path');
4+
const fs = require('fs/promises');
35
const execFile = promisify(require('child_process').execFile);
46
const npa = require('npm-package-arg');
57
const semver = require('semver');
68
const validateNpmPackageName = require('validate-npm-package-name');
9+
const mapWorkspaces = require('@npmcli/map-workspaces');
710

811
// Remove npm env vars from the commands, this
912
// is so it respects the directory it is run in,
@@ -18,23 +21,30 @@ const env = Object.keys(process.env).reduce((e, key) => {
1821

1922
module.exports.install = install;
2023
function install (deps = [], opts = {}) {
21-
if (!deps || !deps.length) {
24+
if (deps !== null && (!deps || !deps.length)) {
2225
return Promise.resolve();
2326
}
2427

2528
let args = ['i'];
26-
if (opts.save === false) {
27-
args.push('--no-save');
28-
} else {
29-
args.push(`--save-${opts.save || 'prod'}`);
30-
if (opts.exact) {
31-
args.push('--save-exact');
29+
30+
// If we are installing deps, respect those flags
31+
if (deps !== null) {
32+
if (opts.save === false) {
33+
args.push('--no-save');
34+
} else {
35+
args.push(`--save-${opts.save || 'prod'}`);
36+
if (opts.exact) {
37+
args.push('--save-exact');
38+
}
39+
if (opts.bundle) {
40+
args.push('--save-bundle');
41+
}
3242
}
33-
if (opts.bundle) {
34-
args.push('--save-bundle');
43+
if (opts.workspace) {
44+
args.push('-w', opts.workspace);
3545
}
46+
args = args.concat(deps);
3647
}
37-
args = args.concat(deps);
3848

3949
return execFile('npm', args, {
4050
env,
@@ -123,3 +133,70 @@ function validatePackageName (name) {
123133
}
124134
return true;
125135
}
136+
137+
module.exports.readPkg = readPkg;
138+
async function readPkg (cwd) {
139+
return JSON.parse(await fs.readFile(path.join(cwd, 'package.json')));
140+
}
141+
142+
async function findNearestPackageJson (cwd, root = '/', filter = (dir, pkg) => !!pkg) {
143+
let pkg;
144+
let _cwd = cwd;
145+
while (_cwd) {
146+
try {
147+
pkg = await readPkg(_cwd);
148+
if (filter(_cwd, pkg)) {
149+
break;
150+
}
151+
} catch (e) {
152+
// ignore
153+
}
154+
155+
if (_cwd === root) {
156+
break;
157+
}
158+
_cwd = path.dirname(_cwd);
159+
}
160+
if (!pkg) {
161+
return [null, cwd];
162+
}
163+
return [pkg, _cwd];
164+
}
165+
166+
module.exports.findWorkspaceRoot = findWorkspaceRoot;
167+
async function findWorkspaceRoot (cwd, root = '/') {
168+
const [firstPkg, dir] = await findNearestPackageJson(cwd, root);
169+
170+
// No package.json found inside root
171+
if (!firstPkg) {
172+
return [null, cwd];
173+
}
174+
175+
// The closest directory with a package.json looks like a workspace root
176+
if (firstPkg.workspaces || root === dir) {
177+
return [firstPkg, dir];
178+
}
179+
180+
const [workspacePkg, dir2] = await findNearestPackageJson(path.dirname(dir), root, (d, pkg) => {
181+
return !!pkg?.workspaces;
182+
});
183+
184+
// Found what looks like a workspace root
185+
if (workspacePkg) {
186+
return [workspacePkg, dir2];
187+
}
188+
189+
// No package in root defining a workspace, so return the first
190+
return [firstPkg, dir];
191+
}
192+
193+
module.exports.searchForWorkspaces = searchForWorkspaces;
194+
async function searchForWorkspaces (cwd = process.cwd(), pkg, pattern = '**/*') {
195+
return mapWorkspaces({
196+
pkg: {
197+
...pkg,
198+
workspaces: [pattern]
199+
},
200+
cwd
201+
});
202+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@
3636
},
3737
"license": "MIT",
3838
"dependencies": {
39+
"@npmcli/map-workspaces": "^5.0.3",
3940
"@npmcli/name-from-folder": "^2.0.0",
4041
"@npmcli/package-json": "^5.0.0",
4142
"fs-extra": "^11.1.1",
4243
"loggerr": "^3.0.0",
4344
"npm-package-arg": "^11.0.1",
44-
"opta": "^1.0.0",
45+
"opta": "^1.1.5",
4546
"safe-parse-list": "^0.1.1",
4647
"validate-npm-package-name": "^5.0.0"
4748
},
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@test/monorepo-no-workspaces",
3+
"version": "0.0.0",
4+
"description": "A test monorepo",
5+
"scripts": {
6+
"test": "exit 0"
7+
},
8+
"author": "Test <tester@example.com>",
9+
"license": "ISC"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@test/monorepo-foo",
3+
"version": "1.0.0",
4+
"description": "A test monorepo package named foo",
5+
"scripts": {
6+
"test": "exit 0"
7+
},
8+
"author": "Test <tester@example.com>",
9+
"license": "ISC"
10+
}

0 commit comments

Comments
 (0)