diff options
author | Ruy Adorno <ruyadorno@hotmail.com> | 2021-04-07 18:03:41 +0300 |
---|---|---|
committer | Ruy Adorno <ruyadorno@hotmail.com> | 2021-04-22 22:06:29 +0300 |
commit | 4c1f16d2c29a7a56c19b97f2820e6305a6075083 (patch) | |
tree | 460fd0dfa65b168d3f35bac5e0ad363b14684a1d /lib | |
parent | 2aecec591df6866e27d0b17dc49cef8f7d738d77 (diff) |
feat: add init workspaces
Add workspaces support to `npm init`
- Fixes `npm exec` respecting `script-shell` option value
- Refactored `lib/exec.js` into `libnpmexec`
- Updates init-package-json@2.0.3
- Added ability to create a new workspace using the -w config
PR-URL: https://github.com/npm/cli/pull/3095
Credit: @ruyadorno
Close: #3095
Reviewed-by: @wraithgar
Diffstat (limited to 'lib')
-rw-r--r-- | lib/exec.js | 302 | ||||
-rw-r--r-- | lib/exec/get-workspace-location-msg.js | 25 | ||||
-rw-r--r-- | lib/init.js | 190 | ||||
-rw-r--r-- | lib/utils/config/definitions.js | 3 |
4 files changed, 216 insertions, 304 deletions
diff --git a/lib/exec.js b/lib/exec.js index f8c76eeed..3da672f99 100644 --- a/lib/exec.js +++ b/lib/exec.js @@ -1,18 +1,6 @@ -const { promisify } = require('util') -const read = promisify(require('read')) -const chalk = require('chalk') -const mkdirp = require('mkdirp-infer-owner') -const readPackageJson = require('read-package-json-fast') -const Arborist = require('@npmcli/arborist') -const runScript = require('@npmcli/run-script') -const { resolve, delimiter } = require('path') -const ciDetect = require('@npmcli/ci-detect') -const crypto = require('crypto') -const pacote = require('pacote') -const npa = require('npm-package-arg') -const fileExists = require('./utils/file-exists.js') -const PATH = require('./utils/path.js') +const libexec = require('libnpmexec') const BaseCommand = require('./base-command.js') +const getLocationMsg = require('./exec/get-workspace-location-msg.js') const getWorkspaces = require('./workspaces/get-workspaces.js') // it's like this: @@ -40,13 +28,6 @@ const getWorkspaces = require('./workspaces/get-workspaces.js') // runScript({ pkg, event: 'npx', ... }) // process.env.npm_lifecycle_event = 'npx' -const nocolor = { - reset: s => s, - bold: s => s, - dim: s => s, - green: s => s, -} - class Exec extends BaseCommand { /* istanbul ignore next - see test/lib/load-all-commands.js */ static get description () { @@ -86,276 +67,50 @@ class Exec extends BaseCommand { // When commands go async and we can dump the boilerplate exec methods this // can be named correctly async _exec (_args, { locationMsg, path, runPath }) { + const args = [..._args] + const cache = this.npm.config.get('cache') const call = this.npm.config.get('call') - const shell = this.npm.config.get('shell') - // dereferenced because we manipulate it later - const packages = [...this.npm.config.get('package')] + const color = this.npm.config.get('color') + const { + flatOptions, + localBin, + log, + globalBin, + output, + } = this.npm + const scriptShell = this.npm.config.get('script-shell') || undefined + const packages = this.npm.config.get('package') + const yes = this.npm.config.get('yes') if (call && _args.length) throw this.usage - const args = [..._args] - const pathArr = [...PATH] - - // nothing to maybe install, skip the arborist dance - if (!call && !args.length && !packages.length) { - return await this.run({ - args, - call, - locationMsg, - shell, - path, - pathArr, - runPath, - }) - } - - const needPackageCommandSwap = args.length && !packages.length - // if there's an argument and no package has been explicitly asked for - // check the local and global bin paths for a binary named the same as - // the argument and run it if it exists, otherwise fall through to - // the behavior of treating the single argument as a package name - if (needPackageCommandSwap) { - let binExists = false - if (await fileExists(`${this.npm.localBin}/${args[0]}`)) { - pathArr.unshift(this.npm.localBin) - binExists = true - } else if (await fileExists(`${this.npm.globalBin}/${args[0]}`)) { - pathArr.unshift(this.npm.globalBin) - binExists = true - } - - if (binExists) { - return await this.run({ - args, - call, - locationMsg, - path, - pathArr, - runPath, - shell, - }) - } - - packages.push(args[0]) - } - - // If we do `npm exec foo`, and have a `foo` locally, then we'll - // always use that, so we don't really need to fetch the manifest. - // So: run npa on each packages entry, and if it is a name with a - // rawSpec==='', then try to readPackageJson at - // node_modules/${name}/package.json, and only pacote fetch if - // that fails. - const manis = await Promise.all(packages.map(async p => { - const spec = npa(p, path) - if (spec.type === 'tag' && spec.rawSpec === '') { - // fall through to the pacote.manifest() approach - try { - const pj = resolve(path, 'node_modules', spec.name) - return await readPackageJson(pj) - } catch (er) {} - } - // Force preferOnline to true so we are making sure to pull in the latest - // This is especially useful if the user didn't give us a version, and - // they expect to be running @latest - return await pacote.manifest(p, { - ...this.npm.flatOptions, - preferOnline: true, - }) - })) - - if (needPackageCommandSwap) - args[0] = this.getBinFromManifest(manis[0]) - - // figure out whether we need to install stuff, or if local is fine - const localArb = new Arborist({ - ...this.npm.flatOptions, - path, - }) - const tree = await localArb.loadActual() - - // do we have all the packages in manifest list? - const needInstall = manis.some(mani => this.manifestMissing(tree, mani)) - - if (needInstall) { - const installDir = this.cacheInstallDir(packages) - await mkdirp(installDir) - const arb = new Arborist({ - ...this.npm.flatOptions, - log: this.npm.log, - path: installDir, - }) - const tree = await arb.loadActual() - - // at this point, we have to ensure that we get the exact same - // version, because it's something that has only ever been installed - // by npm exec in the cache install directory - const add = manis.filter(mani => this.manifestMissing(tree, { - ...mani, - _from: `${mani.name}@${mani.version}`, - })) - .map(mani => mani._from) - .sort((a, b) => a.localeCompare(b)) - - // no need to install if already present - if (add.length) { - if (!this.npm.config.get('yes')) { - // set -n to always say no - if (this.npm.config.get('yes') === false) - throw new Error('canceled') - - if (!process.stdin.isTTY || ciDetect()) { - this.npm.log.warn('exec', `The following package${ - add.length === 1 ? ' was' : 's were' - } not found and will be installed: ${ - add.map((pkg) => pkg.replace(/@$/, '')).join(', ') - }`) - } else { - const addList = add.map(a => ` ${a.replace(/@$/, '')}`) - .join('\n') + '\n' - const prompt = `Need to install the following packages:\n${ - addList - }Ok to proceed? ` - const confirm = await read({ prompt, default: 'y' }) - if (confirm.trim().toLowerCase().charAt(0) !== 'y') - throw new Error('canceled') - } - } - await arb.reify({ - ...this.npm.flatOptions, - log: this.npm.log, - add, - }) - } - pathArr.unshift(resolve(installDir, 'node_modules/.bin')) - } - - return await this.run({ + return libexec({ + ...flatOptions, args, call, + cache, + color, + localBin, locationMsg, + log, + globalBin, + output, + packages, path, - pathArr, runPath, - shell, + scriptShell, + yes, }) } - async run ({ args, call, locationMsg, path, pathArr, runPath, shell }) { - // turn list of args into command string - const script = call || args.shift() || shell - - // do the fakey runScript dance - // still should work if no package.json in cwd - const realPkg = await readPackageJson(`${path}/package.json`) - .catch(() => ({})) - const pkg = { - ...realPkg, - scripts: { - ...(realPkg.scripts || {}), - npx: script, - }, - } - - this.npm.log.disableProgress() - try { - if (script === shell) { - if (process.stdin.isTTY) { - if (ciDetect()) - return this.npm.log.warn('exec', 'Interactive mode disabled in CI environment') - - const color = this.npm.config.get('color') - const colorize = color ? chalk : nocolor - - locationMsg = locationMsg || ` at location:\n${colorize.dim(runPath)}` - - this.npm.output(`${ - colorize.reset('\nEntering npm script environment') - }${ - colorize.reset(locationMsg) - }${ - colorize.bold('\nType \'exit\' or ^D when finished\n') - }`) - } - } - return await runScript({ - ...this.npm.flatOptions, - pkg, - banner: false, - // we always run in cwd, not --prefix - path: runPath, - stdioString: true, - event: 'npx', - args, - env: { - PATH: pathArr.join(delimiter), - }, - stdio: 'inherit', - }) - } finally { - this.npm.log.enableProgress() - } - } - - manifestMissing (tree, mani) { - // if the tree doesn't have a child by that name/version, return true - // true means we need to install it - const child = tree.children.get(mani.name) - // if no child, we have to load it - if (!child) - return true - - // if no version/tag specified, allow whatever's there - if (mani._from === `${mani.name}@`) - return false - - // otherwise the version has to match what we WOULD get - return child.version !== mani.version - } - - getBinFromManifest (mani) { - // if we have a bin matching (unscoped portion of) packagename, use that - // otherwise if there's 1 bin or all bin value is the same (alias), use - // that, otherwise fail - const bin = mani.bin || {} - if (new Set(Object.values(bin)).size === 1) - return Object.keys(bin)[0] - - // XXX probably a util to parse this better? - const name = mani.name.replace(/^@[^/]+\//, '') - if (bin[name]) - return name - - // XXX need better error message - throw Object.assign(new Error('could not determine executable to run'), { - pkgid: mani._id, - }) - } - - cacheInstallDir (packages) { - // only packages not found in ${prefix}/node_modules - return resolve(this.npm.config.get('cache'), '_npx', this.getHash(packages)) - } - - getHash (packages) { - return crypto.createHash('sha512') - .update(packages.sort((a, b) => a.localeCompare(b)).join('\n')) - .digest('hex') - .slice(0, 16) - } - async _execWorkspaces (args, filters) { const workspaces = await getWorkspaces(filters, { path: this.npm.localPrefix }) - const getLocationMsg = async path => { - const color = this.npm.config.get('color') - const colorize = color ? chalk : nocolor - const { _id } = await readPackageJson(`${path}/package.json`) - return ` in workspace ${colorize.green(_id)} at location:\n${colorize.dim(path)}` - } + const color = this.npm.config.get('color') for (const workspacePath of workspaces.values()) { - const locationMsg = await getLocationMsg(workspacePath) + const locationMsg = await getLocationMsg({ color, path: workspacePath }) await this._exec(args, { locationMsg, path: workspacePath, @@ -364,4 +119,5 @@ class Exec extends BaseCommand { } } } + module.exports = Exec diff --git a/lib/exec/get-workspace-location-msg.js b/lib/exec/get-workspace-location-msg.js new file mode 100644 index 000000000..813b11e78 --- /dev/null +++ b/lib/exec/get-workspace-location-msg.js @@ -0,0 +1,25 @@ +const chalk = require('chalk') +const readPackageJson = require('read-package-json-fast') + +const nocolor = { + dim: s => s, + green: s => s, +} + +const getLocationMsg = async ({ color, path }) => { + const colorize = color ? chalk : nocolor + const { _id } = + await readPackageJson(`${path}/package.json`) + .catch(() => ({})) + + const workspaceMsg = _id + ? ` in workspace ${colorize.green(_id)}` + : ` in a ${colorize.green('new')} workspace` + const locationMsg = ` at location:\n${ + colorize.dim(path) + }` + + return `${workspaceMsg}${locationMsg}` +} + +module.exports = getLocationMsg diff --git a/lib/init.js b/lib/init.js index 81c673388..7d7f6bab3 100644 --- a/lib/init.js +++ b/lib/init.js @@ -1,6 +1,14 @@ +const fs = require('fs') +const { relative, resolve } = require('path') +const mkdirp = require('mkdirp-infer-owner') const initJson = require('init-package-json') const npa = require('npm-package-arg') +const rpj = require('read-package-json-fast') +const libexec = require('libnpmexec') +const parseJSON = require('json-parse-even-better-errors') +const mapWorkspaces = require('@npmcli/map-workspaces') +const getLocationMsg = require('./exec/get-workspace-location-msg.js') const BaseCommand = require('./base-command.js') class Init extends BaseCommand { @@ -10,6 +18,11 @@ class Init extends BaseCommand { } /* istanbul ignore next - see test/lib/load-all-commands.js */ + static get params () { + return ['workspace', 'workspaces'] + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ static get name () { return 'init' } @@ -27,42 +40,107 @@ class Init extends BaseCommand { this.init(args).then(() => cb()).catch(cb) } + execWorkspaces (args, filters, cb) { + this.initWorkspaces(args, filters).then(() => cb()).catch(cb) + } + async init (args) { - // the new npx style way + // npm exec style + if (args.length) + return (await this.execCreate({ args, path: process.cwd() })) + + // no args, uses classic init-package-json boilerplate + await this.template() + } + + async initWorkspaces (args, filters) { + // reads package.json for the top-level folder first, by doing this we + // ensure the command throw if no package.json is found before trying + // to create a workspace package.json file or its folders + const pkg = await rpj(resolve(this.npm.localPrefix, 'package.json')) + const wPath = filterArg => resolve(this.npm.localPrefix, filterArg) + + // npm-exec style, runs in the context of each workspace filter if (args.length) { - const initerName = args[0] - let packageName = initerName - if (/^@[^/]+$/.test(initerName)) - packageName = initerName + '/create' - else { - const req = npa(initerName) - if (req.type === 'git' && req.hosted) { - const { user, project } = req.hosted - packageName = initerName - .replace(user + '/' + project, user + '/create-' + project) - } else if (req.registry) { - packageName = req.name.replace(/^(@[^/]+\/)?/, '$1create-') - if (req.rawSpec) - packageName += '@' + req.rawSpec - } else { - throw Object.assign(new Error( - 'Unrecognized initializer: ' + initerName + - '\nFor more package binary executing power check out `npx`:' + - '\nhttps://www.npmjs.com/package/npx' - ), { code: 'EUNSUPPORTED' }) - } + for (const filterArg of filters) { + const path = wPath(filterArg) + await mkdirp(path) + await this.execCreate({ args, path }) + await this.setWorkspace({ pkg, workspacePath: path }) + } + return + } + + // no args, uses classic init-package-json boilerplate + for (const filterArg of filters) { + const path = wPath(filterArg) + await mkdirp(path) + await this.template(path) + await this.setWorkspace({ pkg, workspacePath: path }) + } + } + + async execCreate ({ args, path }) { + const [initerName, ...otherArgs] = args + let packageName = initerName + + if (/^@[^/]+$/.test(initerName)) + packageName = initerName + '/create' + else { + const req = npa(initerName) + if (req.type === 'git' && req.hosted) { + const { user, project } = req.hosted + packageName = initerName + .replace(user + '/' + project, user + '/create-' + project) + } else if (req.registry) { + packageName = req.name.replace(/^(@[^/]+\/)?/, '$1create-') + if (req.rawSpec) + packageName += '@' + req.rawSpec + } else { + throw Object.assign(new Error( + 'Unrecognized initializer: ' + initerName + + '\nFor more package binary executing power check out `npx`:' + + '\nhttps://www.npmjs.com/package/npx' + ), { code: 'EUNSUPPORTED' }) } - this.npm.config.set('package', []) - const newArgs = [packageName, ...args.slice(1)] - return new Promise((res, rej) => { - this.npm.commands.exec(newArgs, er => er ? rej(er) : res()) - }) } - // the old way - const dir = process.cwd() + const newArgs = [packageName, ...otherArgs] + const cache = this.npm.config.get('cache') + const { color } = this.npm.flatOptions + const { + flatOptions, + localBin, + log, + globalBin, + output, + } = this.npm + const locationMsg = await getLocationMsg({ color, path }) + const runPath = path + const scriptShell = this.npm.config.get('script-shell') || undefined + const yes = this.npm.config.get('yes') + + await libexec({ + ...flatOptions, + args: newArgs, + cache, + color, + localBin, + locationMsg, + log, + globalBin, + output, + path, + runPath, + scriptShell, + yes, + }) + } + + async template (path = process.cwd()) { this.npm.log.pause() this.npm.log.disableProgress() + const initFile = this.npm.config.get('init-module') if (!this.npm.config.get('yes') && !this.npm.config.get('force')) { this.npm.output([ @@ -78,9 +156,10 @@ class Init extends BaseCommand { 'Press ^C at any time to quit.', ].join('\n')) } + // XXX promisify init-package-json await new Promise((res, rej) => { - initJson(dir, initFile, this.npm.config, (er, data) => { + initJson(path, initFile, this.npm.config, (er, data) => { this.npm.log.resume() this.npm.log.enableProgress() this.npm.log.silly('package data', data) @@ -97,5 +176,56 @@ class Init extends BaseCommand { }) }) } + + async setWorkspace ({ pkg, workspacePath }) { + const workspaces = await mapWorkspaces({ cwd: this.npm.localPrefix, pkg }) + + // skip setting workspace if current package.json glob already satisfies it + for (const wPath of workspaces.values()) { + if (wPath === workspacePath) + return + } + + // if a create-pkg didn't generate a package.json at the workspace + // folder level, it might not be recognized as a workspace by + // mapWorkspaces, so we're just going to avoid touching the + // top-level package.json + try { + fs.statSync(resolve(workspacePath, 'package.json')) + } catch (err) { + return + } + + let manifest + try { + manifest = + fs.readFileSync(resolve(this.npm.localPrefix, 'package.json'), 'utf-8') + } catch (error) { + throw new Error('package.json not found') + } + + try { + manifest = parseJSON(manifest) + } catch (error) { + throw new Error(`Invalid package.json: ${error}`) + } + + if (!manifest.workspaces) + manifest.workspaces = [] + + manifest.workspaces.push(relative(this.npm.localPrefix, workspacePath)) + + // format content + const { + [Symbol.for('indent')]: indent, + [Symbol.for('newline')]: newline, + } = manifest + + const content = (JSON.stringify(manifest, null, indent) + '\n') + .replace(/\n/g, newline) + + fs.writeFileSync(resolve(this.npm.localPrefix, 'package.json'), content) + } } + module.exports = Init diff --git a/lib/utils/config/definitions.js b/lib/utils/config/definitions.js index db1f25e95..f8c6b41f3 100644 --- a/lib/utils/config/definitions.js +++ b/lib/utils/config/definitions.js @@ -1660,7 +1660,8 @@ define('script-shell', { `, type: [null, String], description: ` - The shell to use for scripts run with the \`npm run\` command. + The shell to use for scripts run with the \`npm exec\`, + \`npm run\` and \`npm init <pkg>\` commands. `, flatten (key, obj, flatOptions) { flatOptions.scriptShell = obj[key] || undefined |