From 06087dff4f12f2b1972ed0f08ccc198ed1c042dc Mon Sep 17 00:00:00 2001 From: Wes Todd Date: Mon, 15 Dec 2025 18:50:06 -0600 Subject: [PATCH] feat: added support for monorepo workspaces --- index.js | 140 ++++++++++++++++-- lib/find-package-json.js | 0 lib/git.js | 24 +++ lib/npm.js | 97 ++++++++++-- package.json | 3 +- .../monorepo-no-workspaces/package.json | 10 ++ .../packages/foo/package.json | 10 ++ test/fixtures/monorepo/package-lock.json | 25 ++++ test/fixtures/monorepo/package.json | 13 ++ .../monorepo/packages/foo/package.json | 10 ++ test/index.js | 14 +- test/monorepo.js | 93 ++++++++++++ 12 files changed, 414 insertions(+), 25 deletions(-) create mode 100644 lib/find-package-json.js create mode 100644 test/fixtures/monorepo-no-workspaces/package.json create mode 100644 test/fixtures/monorepo-no-workspaces/packages/foo/package.json create mode 100644 test/fixtures/monorepo/package-lock.json create mode 100644 test/fixtures/monorepo/package.json create mode 100644 test/fixtures/monorepo/packages/foo/package.json create mode 100644 test/monorepo.js diff --git a/index.js b/index.js index 339077b..3dc9dce 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ const fs = require('fs-extra'); const opta = require('opta'); const parseList = require('safe-parse-list'); const { create, load } = require('@npmcli/package-json'); +const mapWorkspaces = require('@npmcli/map-workspaces'); const { Loggerr } = require('loggerr'); const packageName = require('./lib/package-name'); const git = require('./lib/git'); @@ -123,6 +124,22 @@ function initOpts () { } }, + workspaceRoot: { + type: 'string', + flag: { + key: 'workspace-root' + }, + prompt: { + message: 'Workspace Root:', + when: (promptInput, defaultWhen, allInput) => { + if (allInput.workspaceRoot !== allInput.cwd) { + return true; + } + return defaultWhen; + } + } + }, + type: { type: 'string', prompt: { @@ -255,6 +272,50 @@ async function readPackageJson (options, { log } = {}) { log.error(e); } + // Check to see if we are in a monorepo context + if (!pkg.workspaces) { + let rootPkg; + let rootDir = opts.workspaceRoot; + if (!rootDir) { + // Limit to git root if in a checked out repo + const gitRoot = await git.repositoryRoot(opts.cwd); + + const [_rootPkg, _rootDir] = await npm.findWorkspaceRoot(opts.cwd, gitRoot); + rootPkg = _rootPkg; + rootDir = _rootDir; + } else { + try { + rootPkg = await npm.readPkg(rootDir); + } catch (e) { + if (e.code !== 'ENOENT') { + throw e; + } + // ignore + } + } + + // If we are *not* inside what looks like an existing monorepo setup, + // check what packages may show up and suggest them as defaults + // for the `workspaces` key + if (rootDir === opts.cwd) { + const maybeWorkspaces = await npm.searchForWorkspaces(opts.cwd, pkg); + if (maybeWorkspaces.size) { + pkg.workspaces = []; + for (const wsDir of maybeWorkspaces.values()) { + pkg.workspaces.push(path.relative(opts.cwd, wsDir)); + } + } + } else { + // We are in a workspace context, don't ask about + // workspaces and set the workspaceRoot + options.overrides({ + workspaces: null, + workspaceRoot: rootDir, + workspaceRootPkg: rootPkg + }); + } + } + let author; if (!pkg || !pkg.author) { const gitAuthor = await git.author({ cwd: opts.cwd }); @@ -285,7 +346,8 @@ async function readPackageJson (options, { log } = {}) { repository: repo, keywords: pkg.keywords, scripts: pkg.scripts, - license: pkg.license + license: pkg.license, + workspaces: pkg.workspaces }); return packageInstance.update(pkg); @@ -364,26 +426,86 @@ module.exports.write = write; // TODO: look at https://npm.im/json-file-plus for writing async function write (opts, pkg, { log } = {}) { const pkgPath = path.resolve(opts.cwd, 'package.json'); + + // Ensure directory exists + await fs.mkdirp(path.dirname(pkgPath)); + // Write package json log.info(`Writing package.json\n${pkgPath}`); await pkg.save(); + // If we dont have workspaceRootPkg then we are + // already working on the root workspace package.json + // which means we don't need to do anything with updating + // the monorepo + let workspaceRelativePath = null; + if (opts.workspaceRoot && opts.workspaceRootPkg) { + workspaceRelativePath = path.relative(opts.workspaceRoot, opts.cwd); + + // Check if this wis already part of the workspace + const ws = await mapWorkspaces({ + pkg: opts.workspaceRootPkg, + cwd: opts.workspaceRoot + }); + if (Array.from(ws.values()).includes(opts.cwd)) { + log.debug('Workspaces globs already match the new package path, no update necessary'); + } else { + log.info('Adding new package to workspace root package.json'); + const rootPkg = await load(opts.workspaceRoot); + rootPkg.update({ + workspaces: [...(opts.workspaceRootPkg.workspaces || []), workspaceRelativePath] + }); + await rootPkg.save(); + } + + log.info('Running install', { + directory: opts.workspaceRoot || opts.cwd + }); + const out = await npm.install(null, { + directory: opts.workspaceRoot || opts.cwd + }); + if (out.stderr) { + log.error(out.stderr); + } + log.debug(out.stdout); + } + // Run installs if (opts.dependencies && opts.dependencies.length) { - log.info('Installing dependencies', opts.dependencies); - await npm.install(opts.dependencies, { + log.info('Installing dependencies', { + save: 'prod', + directory: opts.workspaceRoot || opts.cwd, + exact: !!opts.saveExact, + workspace: workspaceRelativePath + }); + const out = await npm.install(opts.dependencies, { save: 'prod', - directory: opts.cwd, - exact: !!opts.saveExact + directory: opts.workspaceRoot || opts.cwd, + exact: !!opts.saveExact, + workspace: workspaceRelativePath }); + if (out.stderr) { + log.error(out.stderr); + } + log.debug(out.stdout); } if (opts.devDependencies && opts.devDependencies.length) { - log.info('Installing dev dependencies', opts.devDependencies); - await npm.install(opts.devDependencies, { + log.info('Installing dev dependencies', { + save: 'dev', + directory: opts.workspaceRoot || opts.cwd, + exact: !!opts.saveExact, + workspace: workspaceRelativePath + }); + const out = await npm.install(opts.devDependencies, { save: 'dev', - directory: opts.cwd, - exact: !!opts.saveExact + directory: opts.workspaceRoot || opts.cwd, + exact: !!opts.saveExact, + workspace: workspaceRelativePath }); + if (out.stderr) { + log.error(out.stderr); + } + log.debug(out.stdout); } // Read full package back to return diff --git a/lib/find-package-json.js b/lib/find-package-json.js new file mode 100644 index 0000000..e69de29 diff --git a/lib/git.js b/lib/git.js index d7637c2..3962083 100644 --- a/lib/git.js +++ b/lib/git.js @@ -68,3 +68,27 @@ module.exports.repository = async function (cwd, pkg) { return pkg.repository.url; } }; + +module.exports.repositoryRoot = async function repositoryRoot (cwd) { + try { + // Since we call this *before* creating the new pacakge directory in a monorepo, + // we need to find the first real directory above `cwd` because otherwise this + // command will succeede but use the system cwd instead + let _cwd = cwd; + while (_cwd !== '/') { + try { + const p = await fs.realpath(_cwd); + await fs.access(p, fs.constants.W_OK | fs.constants.R_OK); + _cwd = p; + break; + } catch (e) { + _cwd = path.dirname(_cwd); + } + } + + const out = await execFile('git', ['rev-parse', '--show-toplevel'], { cwd: _cwd }); + return out.stdout.trim(); + } catch (e) { + // Ignore errors + } +}; diff --git a/lib/npm.js b/lib/npm.js index f64cbe5..5292748 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -1,9 +1,12 @@ 'use strict'; const { promisify } = require('util'); +const path = require('path'); +const fs = require('fs/promises'); const execFile = promisify(require('child_process').execFile); const npa = require('npm-package-arg'); const semver = require('semver'); const validateNpmPackageName = require('validate-npm-package-name'); +const mapWorkspaces = require('@npmcli/map-workspaces'); // Remove npm env vars from the commands, this // is so it respects the directory it is run in, @@ -18,23 +21,30 @@ const env = Object.keys(process.env).reduce((e, key) => { module.exports.install = install; function install (deps = [], opts = {}) { - if (!deps || !deps.length) { + if (deps !== null && (!deps || !deps.length)) { return Promise.resolve(); } let args = ['i']; - if (opts.save === false) { - args.push('--no-save'); - } else { - args.push(`--save-${opts.save || 'prod'}`); - if (opts.exact) { - args.push('--save-exact'); + + // If we are installing deps, respect those flags + if (deps !== null) { + if (opts.save === false) { + args.push('--no-save'); + } else { + args.push(`--save-${opts.save || 'prod'}`); + if (opts.exact) { + args.push('--save-exact'); + } + if (opts.bundle) { + args.push('--save-bundle'); + } } - if (opts.bundle) { - args.push('--save-bundle'); + if (opts.workspace) { + args.push('-w', opts.workspace); } + args = args.concat(deps); } - args = args.concat(deps); return execFile('npm', args, { env, @@ -123,3 +133,70 @@ function validatePackageName (name) { } return true; } + +module.exports.readPkg = readPkg; +async function readPkg (cwd) { + return JSON.parse(await fs.readFile(path.join(cwd, 'package.json'))); +} + +async function findNearestPackageJson (cwd, root = '/', filter = (dir, pkg) => !!pkg) { + let pkg; + let _cwd = cwd; + while (_cwd) { + try { + pkg = await readPkg(_cwd); + if (filter(_cwd, pkg)) { + break; + } + } catch (e) { + // ignore + } + + if (_cwd === root) { + break; + } + _cwd = path.dirname(_cwd); + } + if (!pkg) { + return [null, cwd]; + } + return [pkg, _cwd]; +} + +module.exports.findWorkspaceRoot = findWorkspaceRoot; +async function findWorkspaceRoot (cwd, root = '/') { + const [firstPkg, dir] = await findNearestPackageJson(cwd, root); + + // No package.json found inside root + if (!firstPkg) { + return [null, cwd]; + } + + // The closest directory with a package.json looks like a workspace root + if (firstPkg.workspaces || root === dir) { + return [firstPkg, dir]; + } + + const [workspacePkg, dir2] = await findNearestPackageJson(path.dirname(dir), root, (d, pkg) => { + return !!pkg?.workspaces; + }); + + // Found what looks like a workspace root + if (workspacePkg) { + return [workspacePkg, dir2]; + } + + // No package in root defining a workspace, so return the first + return [firstPkg, dir]; +} + +module.exports.searchForWorkspaces = searchForWorkspaces; +async function searchForWorkspaces (cwd = process.cwd(), pkg, pattern = '**/*') { + return mapWorkspaces({ + pkg: { + ...pkg, + workspaces: [pattern] + }, + cwd + }); +} diff --git a/package.json b/package.json index 1987763..8ae89f5 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,13 @@ }, "license": "MIT", "dependencies": { + "@npmcli/map-workspaces": "^5.0.3", "@npmcli/name-from-folder": "^2.0.0", "@npmcli/package-json": "^5.0.0", "fs-extra": "^11.1.1", "loggerr": "^3.0.0", "npm-package-arg": "^11.0.1", - "opta": "^1.0.0", + "opta": "^1.1.5", "safe-parse-list": "^0.1.1", "validate-npm-package-name": "^5.0.0" }, diff --git a/test/fixtures/monorepo-no-workspaces/package.json b/test/fixtures/monorepo-no-workspaces/package.json new file mode 100644 index 0000000..d558bd9 --- /dev/null +++ b/test/fixtures/monorepo-no-workspaces/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/monorepo-no-workspaces", + "version": "0.0.0", + "description": "A test monorepo", + "scripts": { + "test": "exit 0" + }, + "author": "Test ", + "license": "ISC" +} diff --git a/test/fixtures/monorepo-no-workspaces/packages/foo/package.json b/test/fixtures/monorepo-no-workspaces/packages/foo/package.json new file mode 100644 index 0000000..837952a --- /dev/null +++ b/test/fixtures/monorepo-no-workspaces/packages/foo/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/monorepo-foo", + "version": "1.0.0", + "description": "A test monorepo package named foo", + "scripts": { + "test": "exit 0" + }, + "author": "Test ", + "license": "ISC" +} diff --git a/test/fixtures/monorepo/package-lock.json b/test/fixtures/monorepo/package-lock.json new file mode 100644 index 0000000..cef8aa6 --- /dev/null +++ b/test/fixtures/monorepo/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "@test/monorepo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@test/monorepo", + "version": "0.0.0", + "license": "ISC", + "workspaces": [ + "packages/*" + ] + }, + "node_modules/@test/monorepo-foo": { + "resolved": "packages/foo", + "link": true + }, + "packages/foo": { + "name": "@test/monorepo-foo", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/test/fixtures/monorepo/package.json b/test/fixtures/monorepo/package.json new file mode 100644 index 0000000..b32a9c8 --- /dev/null +++ b/test/fixtures/monorepo/package.json @@ -0,0 +1,13 @@ +{ + "name": "@test/monorepo", + "version": "0.0.0", + "description": "A test monorepo", + "scripts": { + "test": "exit 0" + }, + "author": "Test ", + "license": "ISC", + "workspaces": [ + "packages/*" + ] +} diff --git a/test/fixtures/monorepo/packages/foo/package.json b/test/fixtures/monorepo/packages/foo/package.json new file mode 100644 index 0000000..837952a --- /dev/null +++ b/test/fixtures/monorepo/packages/foo/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/monorepo-foo", + "version": "1.0.0", + "description": "A test monorepo package named foo", + "scripts": { + "test": "exit 0" + }, + "author": "Test ", + "license": "ISC" +} diff --git a/test/index.js b/test/index.js index efa7062..53d853d 100644 --- a/test/index.js +++ b/test/index.js @@ -36,6 +36,10 @@ suite('create-package-json', () => { return createPkgJson({ silent: true, cwd: fix.TMP ? fix.TMP : process.cwd(), + // Set this for all calls to avoid searching + // up to the root of this package instead + // of the root of the fixture + workspaceRoot: fix.TMP, ...opts }, prompt); }; @@ -44,7 +48,6 @@ suite('create-package-json', () => { test('create a new default package.json', async () => { await fix.setup(); const pkg = await createPackageJson({ - cwd: fix.TMP, author: 'Test User ' }, { promptor: () => { @@ -57,10 +60,11 @@ suite('create-package-json', () => { assert.strictEqual(prompts[5].name, 'keywords'); assert.strictEqual(prompts[6].name, 'license'); assert.strictEqual(prompts[7].name, 'workspaces'); - assert.strictEqual(prompts[8].name, 'type'); - assert.strictEqual(prompts[9].name, 'main'); - assert.strictEqual(prompts[10].name, 'dependencies'); - assert.strictEqual(prompts[11].name, 'devDependencies'); + assert.strictEqual(prompts[8].name, 'workspaceRoot'); + assert.strictEqual(prompts[9].name, 'type'); + assert.strictEqual(prompts[10].name, 'main'); + assert.strictEqual(prompts[11].name, 'dependencies'); + assert.strictEqual(prompts[12].name, 'devDependencies'); // Set defaults from prompts const out = await Promise.all(prompts.map(async (p) => { diff --git a/test/monorepo.js b/test/monorepo.js new file mode 100644 index 0000000..b62dc1d --- /dev/null +++ b/test/monorepo.js @@ -0,0 +1,93 @@ +'use strict'; +const { suite, test, before } = require('mocha'); +const assert = require('assert'); +const path = require('path'); +const { promisify } = require('util'); +const execFile = promisify(require('child_process').execFile); +const fixtures = require('fs-test-fixtures'); +const optaUtils = require('opta/utils'); +const createPkgJson = require('..'); + +suite('monorepo', () => { + let fix; + let createPackageJson; + let barePrompt; + + before(() => { + fix = fixtures(); + barePrompt = optaUtils.test.promptModule(); + createPackageJson = async (opts, prompt = barePrompt) => { + return createPkgJson({ + silent: true, + cwd: fix.TMP ? fix.TMP : process.cwd(), + ...opts + }, { + promptor: prompt + }); + }; + }); + + test('create a new default package.json from a monorepo without workspaces', async () => { + await fix.setup('monorepo-no-workspaces'); + const pkg = await createPackageJson({ + workspaceRoot: fix.TMP + }); + + assert.strictEqual(pkg.name, '@test/monorepo-no-workspaces'); + assert.deepStrictEqual(pkg.workspaces, ['packages/foo']); + }); + + test('create a new default package.json in an existing monorepo with workspaces', async () => { + await fix.setup('monorepo'); + const pkg = await createPackageJson(); + + assert.strictEqual(pkg.name, '@test/monorepo'); + assert.deepStrictEqual(pkg.workspaces, ['packages/*']); + }); + + test('create a new workspace package.json in a git repo without workspaces', async () => { + await fix.setup('monorepo-no-workspaces'); + await execFile('git', ['init'], { cwd: fix.TMP }); + + const pkg = await createPackageJson({ + cwd: path.join(fix.TMP, 'packages', 'bar') + }); + + assert.strictEqual(pkg.name, 'bar'); + assert.deepStrictEqual(pkg.workspaces, undefined); + }); + + test('create a new workspace package.json in an existing monorepo', async () => { + await fix.setup('monorepo'); + const pkg = await createPackageJson({ + cwd: path.join(fix.TMP, 'packages', 'bar') + }); + + assert.strictEqual(pkg.name, 'bar'); + assert.strictEqual(pkg.workspaces, undefined); + }); + + test('install dependencies in a workspace aware way', async () => { + await fix.setup('monorepo'); + const pkg = await createPackageJson({ + cwd: path.join(fix.TMP, 'packages', 'bar'), + dependencies: ['english-days'] + }); + + assert.strictEqual(pkg.name, 'bar'); + assert.strictEqual(pkg.workspaces, undefined); + assert.strictEqual(pkg.dependencies['english-days'], '^1.0.0'); + }); + + test('create a new workspace package.json in an existing monorepo not matching a workspace glob', async () => { + await fix.setup('monorepo'); + const pkg = await createPackageJson({ + cwd: path.join(fix.TMP, 'baz'), + dependencies: ['english-days'] + }); + + assert.strictEqual(pkg.name, 'baz'); + assert.strictEqual(pkg.workspaces, undefined); + assert.strictEqual(pkg.dependencies['english-days'], '^1.0.0'); + }); +});