diff --git a/lib/commands/copy.js b/lib/commands/copy.js new file mode 100644 index 0000000000000..4186522149779 --- /dev/null +++ b/lib/commands/copy.js @@ -0,0 +1,137 @@ +const Arborist = require('@npmcli/arborist') +const getWorkspaces = require('../workspaces/get-workspaces.js') +const { join, relative, dirname } = require('path') +const packlist = require('npm-packlist') +const fs = require('@npmcli/fs') + +const BaseCommand = require('../base-command.js') + +class Copy extends BaseCommand { + static description = 'Copy package to new location' + + static name = 'copy' + + static params = [ + 'omit', + 'workspace', + 'workspaces', + 'include-workspace-root', + ] + + static usage = [''] + + async exec (args) { + await this.copyTo(args, true, new Set([])) + } + + // called when --workspace or --workspaces is passed. + async execWorkspaces (args, filters) { + const workspaces = await getWorkspaces(filters, { + path: this.npm.localPrefix, + }) + + await this.copyTo( + args, + this.includeWorkspaceRoot, + new Set(workspaces.values())) + } + + async copyTo (args, includeWorkspaceRoot, workspaces) { + if (args.length !== 1) { + throw this.usageError('Missing required destination argument') + } + const opts = { + ...this.npm.flatOptions, + path: this.npm.localPrefix, + log: this.npm.log, + } + const destination = args[0] + const omit = new Set(this.npm.flatOptions.omit) + + const tree = await new Arborist(opts).loadActual() + + // map of node to location in destination. + const destinations = new Map() + + // calculate the root set of packages. + if (includeWorkspaceRoot) { + const to = join(destination, tree.location) + destinations.set(tree, to) + } + for (const edge of tree.edgesOut.values()) { + if (edge.workspace && workspaces.has(edge.to.realpath)) { + const to = join(destination, edge.to.location) + destinations.set(edge.to, to) + } + } + + // copy the root set of packages and their dependencies. + const tasks = [] + for (const [node, dest] of destinations) { + if (node.isLink && node.target) { + const targetPath = destinations.get(node.target) + if (targetPath == null) { + // This is the first time the link target was seen, it will be the + // only copy in dest, other links to the same target will link to + // this copy. + destinations.set(node.target, dest) + } else { + // The link target is already in the destination + tasks.push(relativeSymlink(targetPath, dest)) + } + } else { + if (node.isWorkspace || node.isRoot) { + // workspace and root packages have not been published so they may + // have files that should be excluded. + tasks.push(copyPacklist(node.target.realpath, dest)) + } else { + // copy the modules files but not dependencies. + const nm = join(node.realpath, 'node_modules') + tasks.push(fs.cp(node.realpath, dest, { + recursive: true, + errorOnExist: false, + filter: src => src !== nm, + })) + } + + // add dependency edges to the queue. + for (const edge of node.edgesOut.values()) { + if (!omit.has(edge.type) && edge.to != null) { + destinations.set( + edge.to, + join( + destinations.get(edge.to.parent) || destination, + relative(edge.to.parent.location, edge.to.location))) + } + } + } + } + await Promise.all(tasks) + } +} +module.exports = Copy + +async function copyPacklist (from, to) { + for (const file of await packlist({ path: from })) { + // packlist will include bundled node_modules. ignore it because we're + // already handling copying dependencies. + if (file.startsWith('node_modules/')) { + continue + } + + // using recursive copy because packlist doesn't list directories. + // TODO what is npm's preferred recursive copy? + await fs.cp( + join(from, file), + join(to, file), + { recursive: true, errorOnExist: false }) + } +} + +async function relativeSymlink (target, path) { + await fs.mkdir(dirname(path), { recursive: true }) + await fs.symlink( + './' + relative(dirname(path), target), + path // link to create + ) +} diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 26da539006588..e76ac43c87dc3 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -21,6 +21,7 @@ const shorthands = { 'clean-install-test': 'cit', x: 'exec', why: 'explain', + cp: 'copy', } const affordances = { @@ -134,6 +135,7 @@ const cmdList = [ 'doctor', 'exec', 'explain', + 'copy', ] const plumbing = ['birthday', 'help-search'] diff --git a/package.json b/package.json index 636ef21e5fb64..cb04de7d64d77 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "cli-table3": "^0.6.0", "columnify": "~1.5.4", "fastest-levenshtein": "^1.0.12", + "fs-extra": "^10.0.0", "glob": "^7.2.0", "graceful-fs": "^4.2.8", "hosted-git-info": "^4.0.2", diff --git a/tap-snapshots/smoke-tests/index.js.test.cjs b/tap-snapshots/smoke-tests/index.js.test.cjs index c1316e04d7ad9..b74b4f7a4cbf9 100644 --- a/tap-snapshots/smoke-tests/index.js.test.cjs +++ b/tap-snapshots/smoke-tests/index.js.test.cjs @@ -22,11 +22,11 @@ npm help npm more involved overview All commands: access, adduser, audit, bin, bugs, cache, ci, completion, - config, dedupe, deprecate, diff, dist-tag, docs, doctor, - edit, exec, explain, explore, find-dupes, fund, get, help, - hook, init, install, install-ci-test, install-test, link, - ll, login, logout, ls, org, outdated, owner, pack, ping, - pkg, prefix, profile, prune, publish, rebuild, repo, + config, copy, dedupe, deprecate, diff, dist-tag, docs, + doctor, edit, exec, explain, explore, find-dupes, fund, get, + help, hook, init, install, install-ci-test, install-test, + link, ll, login, logout, ls, org, outdated, owner, pack, + ping, pkg, prefix, profile, prune, publish, rebuild, repo, restart, root, run-script, search, set, set-script, shrinkwrap, star, stars, start, stop, team, test, token, uninstall, unpublish, unstar, update, version, view, whoami diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index 13a3f66ef7d15..b25f52bbb0cfd 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -110,6 +110,7 @@ Array [ doctor exec explain + copy un rb list @@ -131,6 +132,7 @@ Array [ clean-install-test x why + cp la verison ic diff --git a/tap-snapshots/test/lib/load-all-commands.js.test.cjs b/tap-snapshots/test/lib/load-all-commands.js.test.cjs index 6efecf2089e83..faff962079a3c 100644 --- a/tap-snapshots/test/lib/load-all-commands.js.test.cjs +++ b/tap-snapshots/test/lib/load-all-commands.js.test.cjs @@ -160,6 +160,24 @@ alias: c Run "npm help config" for more info ` +exports[`test/lib/load-all-commands.js TAP load each command copy > must match snapshot 1`] = ` +npm copy + +Copy package to new location + +Usage: +npm copy + +Options: +[--omit [--omit ...]] +[-w|--workspace [-w|--workspace ...]] +[-ws|--workspaces] [--include-workspace-root] + +alias: cp + +Run "npm help copy" for more info +` + exports[`test/lib/load-all-commands.js TAP load each command dedupe > must match snapshot 1`] = ` npm dedupe diff --git a/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs b/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs index 971580792048b..89db160c515f0 100644 --- a/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs +++ b/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs @@ -52,6 +52,7 @@ Object { "cit": "install-ci-test", "clean-install": "ci", "clean-install-test": "cit", + "cp": "copy", "create": "init", "ddp": "dedupe", "dist-tags": "dist-tag", @@ -169,6 +170,7 @@ Object { "doctor", "exec", "explain", + "copy", ], "plumbing": Array [ "birthday", @@ -188,6 +190,7 @@ Object { "cit": "install-ci-test", "clean-install": "ci", "clean-install-test": "cit", + "cp": "copy", "create": "init", "ddp": "dedupe", "i": "install", diff --git a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs index 0e48cfa613149..4b27a655e8b77 100644 --- a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs +++ b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs @@ -22,11 +22,11 @@ npm help npm more involved overview All commands: access, adduser, audit, bin, bugs, cache, ci, completion, - config, dedupe, deprecate, diff, dist-tag, docs, doctor, - edit, exec, explain, explore, find-dupes, fund, get, help, - hook, init, install, install-ci-test, install-test, link, - ll, login, logout, ls, org, outdated, owner, pack, ping, - pkg, prefix, profile, prune, publish, rebuild, repo, + config, copy, dedupe, deprecate, diff, dist-tag, docs, + doctor, edit, exec, explain, explore, find-dupes, fund, get, + help, hook, init, install, install-ci-test, install-test, + link, ll, login, logout, ls, org, outdated, owner, pack, + ping, pkg, prefix, profile, prune, publish, rebuild, repo, restart, root, run-script, search, set, set-script, shrinkwrap, star, stars, start, stop, team, test, token, uninstall, unpublish, unstar, update, version, view, whoami @@ -58,11 +58,11 @@ npm help npm more involved overview All commands: access, adduser, audit, bin, bugs, cache, ci, completion, - config, dedupe, deprecate, diff, dist-tag, docs, doctor, - edit, exec, explain, explore, find-dupes, fund, get, help, - hook, init, install, install-ci-test, install-test, link, - ll, login, logout, ls, org, outdated, owner, pack, ping, - pkg, prefix, profile, prune, publish, rebuild, repo, + config, copy, dedupe, deprecate, diff, dist-tag, docs, + doctor, edit, exec, explain, explore, find-dupes, fund, get, + help, hook, init, install, install-ci-test, install-test, + link, ll, login, logout, ls, org, outdated, owner, pack, + ping, pkg, prefix, profile, prune, publish, rebuild, repo, restart, root, run-script, search, set, set-script, shrinkwrap, star, stars, start, stop, team, test, token, uninstall, unpublish, unstar, update, version, view, whoami @@ -94,11 +94,11 @@ npm help npm more involved overview All commands: access, adduser, audit, bin, bugs, cache, ci, completion, - config, dedupe, deprecate, diff, dist-tag, docs, doctor, - edit, exec, explain, explore, find-dupes, fund, get, help, - hook, init, install, install-ci-test, install-test, link, - ll, login, logout, ls, org, outdated, owner, pack, ping, - pkg, prefix, profile, prune, publish, rebuild, repo, + config, copy, dedupe, deprecate, diff, dist-tag, docs, + doctor, edit, exec, explain, explore, find-dupes, fund, get, + help, hook, init, install, install-ci-test, install-test, + link, ll, login, logout, ls, org, outdated, owner, pack, + ping, pkg, prefix, profile, prune, publish, rebuild, repo, restart, root, run-script, search, set, set-script, shrinkwrap, star, stars, start, stop, team, test, token, uninstall, unpublish, unstar, update, version, view, whoami @@ -130,11 +130,11 @@ npm help npm more involved overview (in a browser) All commands: access, adduser, audit, bin, bugs, cache, ci, completion, - config, dedupe, deprecate, diff, dist-tag, docs, doctor, - edit, exec, explain, explore, find-dupes, fund, get, help, - hook, init, install, install-ci-test, install-test, link, - ll, login, logout, ls, org, outdated, owner, pack, ping, - pkg, prefix, profile, prune, publish, rebuild, repo, + config, copy, dedupe, deprecate, diff, dist-tag, docs, + doctor, edit, exec, explain, explore, find-dupes, fund, get, + help, hook, init, install, install-ci-test, install-test, + link, ll, login, logout, ls, org, outdated, owner, pack, + ping, pkg, prefix, profile, prune, publish, rebuild, repo, restart, root, run-script, search, set, set-script, shrinkwrap, star, stars, start, stop, team, test, token, uninstall, unpublish, unstar, update, version, view, whoami @@ -302,6 +302,22 @@ All commands: Run "npm help config" for more info + copy npm copy + + Copy package to new location + + Usage: + npm copy + + Options: + [--omit [--omit ...]] + [-w|--workspace [-w|--workspace ...]] + [-ws|--workspaces] [--include-workspace-root] + + alias: cp + + Run "npm help copy" for more info + dedupe npm dedupe Reduce duplication in the package tree diff --git a/test/lib/commands/copy.js b/test/lib/commands/copy.js new file mode 100644 index 0000000000000..6ec468d982293 --- /dev/null +++ b/test/lib/commands/copy.js @@ -0,0 +1,214 @@ +const t = require('tap') +const { load } = require('../../fixtures/mock-npm') +const path = require('path') +const fs = require('fs') + +const cwd = process.cwd() +t.afterEach(t => process.chdir(cwd)) + +t.test('should copy module files to destination', async t => { + const { npm, outputs, logs } = await load(t, { + testdir: { + 'package.json': JSON.stringify({ + name: 'test-package', + version: '1.0.0', + files: ['lib'], + }), + 'README.md': 'file', + lib: { + 'index.js': '// empty', + }, + src: { + 'index.js': '// empty', + }, + }, + }) + process.chdir(npm.prefix) + await npm.exec('copy', ['build']) + t.strictSame(outputs, []) + t.strictSame(logs.notice, []) + + // lib is includes in files, while package.json and README.md are always included. + assertExists(path.join('build', 'package.json')) + assertExists(path.join('build', 'README.md')) + assertExists(path.join('build', 'lib', 'index.js')) + + // src should not be copied because it's excluded by files. + assertMissing(path.join('build', 'src')) +}) + +t.test('should copy dependencies', async t => { + const { npm, outputs, logs } = await load(t, { + testdir: { + 'package.json': JSON.stringify({ + name: 'test-package', + version: '1.0.0', + dependencies: { + foo: '^1.0.0', + bar: '^1.0.0', + }, + }), + node_modules: { + foo: { }, + bar: { }, + baz: { }, + }, + }, + }) + process.chdir(npm.prefix) + await npm.exec('copy', ['build']) + t.strictSame(outputs, []) + t.strictSame(logs.notice, []) + + assertExists(path.join('build', 'node_modules', 'foo')) + assertExists(path.join('build', 'node_modules', 'bar')) + // baz is missing because it is an extraneous dep. + assertMissing(path.join('build', 'node_modules', 'baz')) +}) + +t.test('should not copy bundled dependencies if they are omitted', async t => { + const { npm, outputs, logs } = await load(t, { + testdir: { + 'package.json': JSON.stringify({ + name: 'test-package', + version: '1.0.0', + bundledDependencies: true, + optionalDependencies: { + foo: '^1.0.0', + }, + }), + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }), + }, + }, + }, + }) + process.chdir(npm.prefix) + npm.config.set('omit', ['optional']) + await npm.exec('copy', ['build']) + t.strictSame(outputs, []) + t.strictSame(logs.notice, []) + + assertMissing(path.join('build', 'node_modules', 'foo')) +}) + +t.test('workspaces', async t => { + const fixture = { + testdir: { + 'package.json': JSON.stringify({ + name: 'workspace-root', + workspaces: [ + 'pkgs/a', + 'pkgs/b', + ], + }), + pkgs: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + }), + 'README.md': 'a', + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.0.0', + dependencies: { c: '^1.0.0' }, + }), + 'README.md': 'b', + }, + }, + node_modules: { + a: t.fixture('symlink', '../pkgs/a'), + b: t.fixture('symlink', '../pkgs/b'), + c: t.fixture('symlink', '../pkgs/a'), + }, + }, + } + + t.test('should only copy included workspaces', async t => { + const { npm } = await load(t, fixture) + process.chdir(npm.prefix) + npm.config.set('workspace', ['a']) + await npm.exec('copy', ['build']) + + assertExists(path.join('build', 'node_modules', 'a')) + assertMissing(path.join('build', 'node_modules', 'b')) + assertMissing(path.join('build', 'package.json')) + }) + + t.test('should copy all included workspaces', async t => { + const { npm } = await load(t, fixture) + process.chdir(npm.prefix) + npm.config.set('workspace', ['a', 'b']) + await npm.exec('copy', ['build']) + + assertExists(path.join('build', 'node_modules', 'a')) + assertExists(path.join('build', 'node_modules', 'b')) + assertMissing(path.join('build', 'package.json')) + }) + + t.test('should copy workspace root', async t => { + const { npm } = await load(t, fixture) + process.chdir(npm.prefix) + npm.config.set('workspaces', true) + npm.config.set('include-workspace-root', true) + await npm.exec('copy', ['build']) + + assertExists(path.join('build', 'node_modules', 'a')) + assertExists(path.join('build', 'node_modules', 'b')) + assertExists(path.join('build', 'package.json')) + }) + + t.test('should copy symlinks once', async t => { + const { npm } = await load(t, fixture) + process.chdir(npm.prefix) + npm.config.set('workspaces', true) + await npm.exec('copy', ['build']) + const canonPath = path.join('build', 'node_modules', 'a') + const linkPath = path.join('build', 'node_modules', 'c') + t.strictSame( + fs.statSync(canonPath), + fs.statSync(linkPath), + `${linkPath} should be a link to ${canonPath}`) + }) +}) + +t.test('requires destination argument', async t => { + const { npm, outputs } = await load(t) + process.chdir(npm.prefix) + await t.rejects( + npm.exec('copy', []), + /Missing required destination argument/ + ) + t.strictSame(outputs, []) +}) + +function assertExists (path) { + try { + fs.statSync(path) + t.pass(`${path} exists`) + } catch (err) { + if (err.code === 'ENOENT') { + return t.fail(`${path} should exist but does not`) + } + throw err + } +} + +function assertMissing (path) { + try { + fs.statSync(path) + t.fail(`${path} should not exist but does`) + } catch (err) { + if (err.code === 'ENOENT') { + return t.pass(`${path} does not exist`) + } + throw err + } +}