Skip to content

Commit 9902da5

Browse files
committed
feat: added support for monorepo workspaces
1 parent 38cc19d commit 9902da5

10 files changed

Lines changed: 327 additions & 21 deletions

File tree

index.js

Lines changed: 117 additions & 5 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,47 @@ 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+
const [_rootPkg, _rootDir] = await npm.findWorkspaceRoot(opts.cwd);
281+
rootPkg = _rootPkg;
282+
rootDir = _rootDir;
283+
} else {
284+
try {
285+
rootPkg = await npm.readPkg(rootDir);
286+
} catch (e) {
287+
if (e.code !== 'ENOENT') {
288+
throw e;
289+
}
290+
// ignore
291+
}
292+
}
293+
294+
// If we are *not* inside what looks like an existing monorepo setup,
295+
// check what packages may show up and suggest them as defaults
296+
// for the `workspaces` key
297+
if (rootDir === opts.cwd) {
298+
const maybeWorkspaces = await npm.searchForWorkspaces(opts.cwd, pkg);
299+
if (maybeWorkspaces.size) {
300+
pkg.workspaces = [];
301+
for (const wsDir of maybeWorkspaces.values()) {
302+
pkg.workspaces.push(path.relative(opts.cwd, wsDir));
303+
}
304+
}
305+
} else {
306+
// We are in a workspace context, don't ask about
307+
// workspaces and set the workspaceRoot
308+
options.overrides({
309+
workspaces: null,
310+
workspaceRoot: rootDir,
311+
workspaceRootPkg: rootPkg
312+
});
313+
}
314+
}
315+
258316
let author;
259317
if (!pkg || !pkg.author) {
260318
const gitAuthor = await git.author({ cwd: opts.cwd });
@@ -285,7 +343,8 @@ async function readPackageJson (options, { log } = {}) {
285343
repository: repo,
286344
keywords: pkg.keywords,
287345
scripts: pkg.scripts,
288-
license: pkg.license
346+
license: pkg.license,
347+
workspaces: pkg.workspaces
289348
});
290349

291350
return packageInstance.update(pkg);
@@ -364,26 +423,79 @@ module.exports.write = write;
364423
// TODO: look at https://npm.im/json-file-plus for writing
365424
async function write (opts, pkg, { log } = {}) {
366425
const pkgPath = path.resolve(opts.cwd, 'package.json');
426+
427+
// Ensure directory exists
428+
await fs.mkdirp(path.dirname(pkgPath));
429+
367430
// Write package json
368431
log.info(`Writing package.json\n${pkgPath}`);
369432
await pkg.save();
370433

434+
let shouldRunFinalInstall = false;
435+
436+
// If we dont have workspaceRootPkg then we are
437+
// already working on the root workspace package.json
438+
// which means we don't need to do anything with updating
439+
// the monorepo
440+
let workspaceRelativePath = null;
441+
if (opts.workspaceRoot && opts.workspaceRootPkg) {
442+
workspaceRelativePath = path.relative(opts.workspaceRoot, opts.cwd);
443+
444+
// Check if this wis already part of the workspace
445+
const ws = await mapWorkspaces({
446+
pkg: opts.workspaceRootPkg,
447+
cwd: opts.workspaceRoot
448+
});
449+
if (Array.from(ws.values()).includes(opts.cwd)) {
450+
log.debug('New package already exists in workspaces');
451+
} else {
452+
log.info('Adding new package to workspace root package.json');
453+
const rootPkg = await load(opts.workspaceRoot);
454+
rootPkg.update({
455+
workspaces: [...opts.workspaceRootPkg.workspaces, workspaceRelativePath]
456+
});
457+
await rootPkg.save();
458+
}
459+
460+
// We should run a final install for a new package
461+
// in a monorepo even if it already matches the glob
462+
shouldRunFinalInstall = true;
463+
}
464+
371465
// Run installs
372466
if (opts.dependencies && opts.dependencies.length) {
373467
log.info('Installing dependencies', opts.dependencies);
374468
await npm.install(opts.dependencies, {
375469
save: 'prod',
376-
directory: opts.cwd,
377-
exact: !!opts.saveExact
470+
directory: opts.workspaceRoot || opts.cwd,
471+
exact: !!opts.saveExact,
472+
workspace: workspaceRelativePath
378473
});
474+
475+
// If we run either of these installs we can skip the final one
476+
shouldRunFinalInstall = false;
379477
}
380478
if (opts.devDependencies && opts.devDependencies.length) {
381479
log.info('Installing dev dependencies', opts.devDependencies);
382480
await npm.install(opts.devDependencies, {
383481
save: 'dev',
384-
directory: opts.cwd,
385-
exact: !!opts.saveExact
482+
directory: opts.workspaceRoot || opts.cwd,
483+
exact: !!opts.saveExact,
484+
workspace: workspaceRelativePath
485+
});
486+
487+
// If we run either of these installs we can skip the final one
488+
shouldRunFinalInstall = false;
489+
}
490+
491+
if (shouldRunFinalInstall) {
492+
log.info('Running final install');
493+
await npm.install(null, {
494+
directory: opts.workspaceRoot || opts.cwd
386495
});
496+
497+
// If we run either of these installs we can skip the final one
498+
shouldRunFinalInstall = false;
387499
}
388500

389501
// Read full package back to return

lib/find-package-json.js

Whitespace-only changes.

lib/npm.js

Lines changed: 77 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,60 @@ 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, filter = (pkg) => !!pkg) {
143+
let pkg;
144+
let _cwd = cwd;
145+
while (_cwd !== '/') {
146+
try {
147+
pkg = await readPkg(_cwd);
148+
if (!filter(pkg)) {
149+
_cwd = path.dirname(_cwd);
150+
} else {
151+
break;
152+
}
153+
} catch (e) {
154+
_cwd = path.dirname(_cwd);
155+
}
156+
}
157+
if (!pkg) {
158+
return [null, cwd];
159+
}
160+
return [pkg, _cwd];
161+
}
162+
163+
module.exports.findWorkspaceRoot = findWorkspaceRoot;
164+
async function findWorkspaceRoot (cwd) {
165+
const [firstPkg, dir] = await findNearestPackageJson(cwd);
166+
// The current directory looks like a root
167+
if (firstPkg?.workspaces) {
168+
return [firstPkg, dir];
169+
}
170+
const [workspacePkg, _dir] = await findNearestPackageJson(path.dirname(cwd), (pkg) => {
171+
return !!pkg?.workspaces;
172+
});
173+
174+
// No package above defining a workspace, so return the first or just cwd
175+
if (!workspacePkg) {
176+
return [firstPkg, dir];
177+
}
178+
179+
// Check if this original cwd is part of the workspace
180+
return [workspacePkg, _dir];
181+
}
182+
183+
module.exports.searchForWorkspaces = searchForWorkspaces;
184+
async function searchForWorkspaces (cwd = process.cwd(), pkg, pattern = '**/*') {
185+
return mapWorkspaces({
186+
pkg: {
187+
...pkg,
188+
workspaces: [pattern]
189+
},
190+
cwd
191+
});
192+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@
3434
},
3535
"license": "MIT",
3636
"dependencies": {
37+
"@npmcli/map-workspaces": "^5.0.3",
3738
"@npmcli/name-from-folder": "^2.0.0",
3839
"@npmcli/package-json": "^5.0.0",
3940
"fs-extra": "^11.1.1",
4041
"loggerr": "^3.0.0",
4142
"npm-package-arg": "^11.0.1",
42-
"opta": "^1.0.0",
43+
"opta": "^1.1.5",
4344
"safe-parse-list": "^0.1.1",
4445
"validate-npm-package-name": "^5.0.0"
4546
},
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+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "@test/monorepo",
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+
"workspaces": [
11+
"packages/*"
12+
]
13+
}
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+
}

test/index.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ suite('create-package-json', () => {
3636
return createPkgJson({
3737
silent: true,
3838
cwd: fix.TMP ? fix.TMP : process.cwd(),
39+
// Set this for all calls to avoid searching
40+
// up to the root of this package instead
41+
// of the root of the fixture
42+
workspaceRoot: fix.TMP,
3943
...opts
4044
}, prompt);
4145
};
@@ -44,7 +48,6 @@ suite('create-package-json', () => {
4448
test('create a new default package.json', async () => {
4549
await fix.setup();
4650
const pkg = await createPackageJson({
47-
cwd: fix.TMP,
4851
author: 'Test User <fake@user.com>'
4952
}, {
5053
promptor: () => {
@@ -57,10 +60,11 @@ suite('create-package-json', () => {
5760
assert.strictEqual(prompts[5].name, 'keywords');
5861
assert.strictEqual(prompts[6].name, 'license');
5962
assert.strictEqual(prompts[7].name, 'workspaces');
60-
assert.strictEqual(prompts[8].name, 'type');
61-
assert.strictEqual(prompts[9].name, 'main');
62-
assert.strictEqual(prompts[10].name, 'dependencies');
63-
assert.strictEqual(prompts[11].name, 'devDependencies');
63+
assert.strictEqual(prompts[8].name, 'workspaceRoot');
64+
assert.strictEqual(prompts[9].name, 'type');
65+
assert.strictEqual(prompts[10].name, 'main');
66+
assert.strictEqual(prompts[11].name, 'dependencies');
67+
assert.strictEqual(prompts[12].name, 'devDependencies');
6468

6569
// Set defaults from prompts
6670
const out = await Promise.all(prompts.map(async (p) => {

0 commit comments

Comments
 (0)