diff --git a/lib/link.js b/lib/link.js index 0a1fbc52fb0e8..5318f9029eb46 100644 --- a/lib/link.js +++ b/lib/link.js @@ -1,14 +1,15 @@ -// link with no args: symlink the folder to the global location -// link with package arg: symlink the global to the local +'use strict' + +const { readdir } = require('fs') +const { resolve } = require('path') + +const Arborist = require('@npmcli/arborist') const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const reifyOutput = require('./utils/reify-output.js') -const { resolve } = require('path') -const Arborist = require('@npmcli/arborist') const completion = (opts, cb) => { - const { readdir } = require('fs') const dir = npm.globalDir readdir(dir, (er, files) => cb(er, files.filter(f => !/^[._-]/.test(f)))) } @@ -16,26 +17,33 @@ const completion = (opts, cb) => { const usage = usageUtil( 'link', 'npm link (in package dir)' + - '\nnpm link [<@scope>/][@]' + '\nnpm link [<@scope>/]' ) const cmd = (args, cb) => link(args).then(() => cb()).catch(cb) const link = async args => { if (npm.config.get('global')) { - throw new Error( - 'link should never be --global.\n' + - 'Please re-run this command with --local' + throw Object.assign( + new Error( + 'link should never be --global.\n' + + 'Please re-run this command with --local' + ), + { code: 'ELINKGLOBAL' } ) } + // link with no args: symlink the folder to the global location + // link with package arg: symlink the global to the local args = args.filter(a => resolve(a) !== npm.prefix) - return args.length ? linkInstall(args) : linkPkg() + return args.length + ? linkInstall(args) + : linkPkg() } const linkInstall = async args => { - // add all the args as global installs, and then add symlink installs locally - // to the packages in the global space. + // load current packages from the global space, + // and then add symlinks installs locally const globalTop = resolve(npm.globalDir, '..') const globalArb = new Arborist({ ...npm.flatOptions, @@ -43,24 +51,29 @@ const linkInstall = async args => { global: true }) - const globals = await globalArb.reify({ add: args }) + const globals = await globalArb.loadActual() + + const links = [ + ...globals.children.values() + ] + .filter(i => args.some(j => j === i.name)) - const links = globals.edgesOut.keys() const localArb = new Arborist({ ...npm.flatOptions, path: npm.prefix }) await localArb.reify({ - add: links.map(l => `file:${resolve(globalTop, 'node_modules', l)}`) + add: links.map(l => `file:${resolve(globalTop, 'node_modules', l.path)}`) }) reifyOutput(localArb) } const linkPkg = async () => { + const globalTop = resolve(npm.globalDir, '..') const arb = new Arborist({ ...npm.flatOptions, - path: resolve(npm.globalDir, '..'), + path: globalTop, global: true }) await arb.reify({ add: [`file:${npm.prefix}`] }) diff --git a/tap-snapshots/test-lib-link.js-TAP.test.js b/tap-snapshots/test-lib-link.js-TAP.test.js new file mode 100644 index 0000000000000..211417d3150ea --- /dev/null +++ b/tap-snapshots/test-lib-link.js-TAP.test.js @@ -0,0 +1,19 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/lib/link.js TAP link global linked pkg to local nm when using args > should create a local symlink to global pkg 1`] = ` +{CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/my-project/node_modules/a -> {CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/global-prefix/lib/node_modules/a +{CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/my-project/node_modules/@myscope/bar -> {CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/global-prefix/lib/node_modules/@myscope/bar +{CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/my-project/node_modules/test-pkg-link -> {CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/test-pkg-link +{CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/my-project/node_modules/@myscope/linked -> {CWD}/test/lib/link-link-global-linked-pkg-to-local-nm-when-using-args/scoped-linked + +` + +exports[`test/lib/link.js TAP link to globalDir when in current working dir of pkg and no args > should create a global link to current pkg 1`] = ` +{CWD}/test/lib/link-link-to-globalDir-when-in-current-working-dir-of-pkg-and-no-args/global-prefix/lib/node_modules/test-pkg-link -> {CWD}/test/lib/link-link-to-globalDir-when-in-current-working-dir-of-pkg-and-no-args/test-pkg-link + +` diff --git a/test/lib/link.js b/test/lib/link.js new file mode 100644 index 0000000000000..056b0d3d6f446 --- /dev/null +++ b/test/lib/link.js @@ -0,0 +1,218 @@ +const { resolve } = require('path') + +const Arborist = require('@npmcli/arborist') +const t = require('tap') +const requireInject = require('require-inject') + +const redactCwd = (path) => { + const normalizePath = p => p + .replace(/\\+/g, '/') + .replace(/\r\n/g, '\n') + return normalizePath(path) + .replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}') +} + +t.cleanSnapshot = (str) => redactCwd(str) + +let reifyOutput +const npm = { + globalDir: null, + prefix: null, + flatOptions: {}, + config: { + get () { return false } + } +} +const printLinks = async (opts) => { + let res = '' + const arb = new Arborist(opts) + const tree = await arb.loadActual() + const linkedItems = [...tree.inventory.values()] + for (const item of linkedItems) { + if (item.target) + res += `${item.path} -> ${item.target.path}\n` + } + return res +} + +const mocks = { + '../../lib/npm.js': npm, + '../../lib/utils/reify-output.js': () => reifyOutput() +} + +const link = requireInject('../../lib/link.js', mocks) + +t.test('link to globalDir when in current working dir of pkg and no args', (t) => { + t.plan(2) + + const testdir = t.testdir({ + 'global-prefix': { + lib: { + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0' + }) + } + } + } + }, + 'test-pkg-link': { + 'package.json': JSON.stringify({ + name: 'test-pkg-link', + version: '1.0.0' + }) + } + }) + npm.globalDir = resolve(testdir, 'global-prefix', 'lib', 'node_modules') + npm.prefix = resolve(testdir, 'test-pkg-link') + + reifyOutput = async () => { + reifyOutput = undefined + + const links = await printLinks({ + path: resolve(npm.globalDir, '..'), + global: true + }) + + t.matchSnapshot(links, 'should create a global link to current pkg') + } + + link([], (err) => { + t.ifError(err, 'should not error out') + }) +}) + +t.test('link global linked pkg to local nm when using args', (t) => { + t.plan(2) + + const testdir = t.testdir({ + 'global-prefix': { + lib: { + node_modules: { + '@myscope': { + foo: { + 'package.json': JSON.stringify({ + name: '@myscope/foo', + version: '1.0.0' + }) + }, + bar: { + 'package.json': JSON.stringify({ + name: '@myscope/bar', + version: '1.0.0' + }) + }, + linked: t.fixture('symlink', '../../../../scoped-linked') + }, + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0' + }) + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.0.0' + }) + }, + 'test-pkg-link': t.fixture('symlink', '../../../test-pkg-link') + } + } + }, + 'test-pkg-link': { + 'package.json': JSON.stringify({ + name: 'test-pkg-link', + version: '1.0.0' + }) + }, + 'scoped-linked': { + 'package.json': JSON.stringify({ + name: '@myscope/linked', + version: '1.0.0' + }) + }, + 'my-project': { + 'package.json': JSON.stringify({ + name: 'my-project', + version: '1.0.0', + dependencies: { + foo: '^1.0.0' + } + }), + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0' + }) + } + } + } + }) + npm.globalDir = resolve(testdir, 'global-prefix', 'lib', 'node_modules') + npm.prefix = resolve(testdir, 'my-project') + + reifyOutput = async () => { + reifyOutput = undefined + + const links = await printLinks({ + path: npm.prefix + }) + + t.matchSnapshot(links, 'should create a local symlink to global pkg') + } + + // installs examples for: + // - test-pkg-link: pkg linked to globalDir from local fs + // - @myscope/linked: scoped pkg linked to globalDir from local fs + // - @myscope/bar: prev installed scoped package available in globalDir + // - a: prev installed package available in globalDir + link(['test-pkg-link', '@myscope/linked', '@myscope/bar', 'a'], (err) => { + t.ifError(err, 'should not error out') + }) +}) + +t.test('completion', (t) => { + const testdir = t.testdir({ + 'global-prefix': { + lib: { + node_modules: { + foo: {}, + bar: {}, + lorem: {}, + ipsum: {} + } + } + } + }) + npm.globalDir = resolve(testdir, 'global-prefix', 'lib', 'node_modules') + + link.completion({}, (err, words) => { + t.ifError(err, 'should not error out') + t.deepEqual( + words, + ['bar', 'foo', 'ipsum', 'lorem'], + 'should list all package names available in globalDir' + ) + t.end() + }) +}) + +t.test('--global option', (t) => { + const _config = npm.config + npm.config = { get () { return true } } + link([], (err) => { + npm.config = _config + + t.match( + err.message, + /link should never be --global/, + 'should throw an useful error' + ) + + t.end() + }) +})