From 50d81bc514b019a549312f436c4558c93e3102d6 Mon Sep 17 00:00:00 2001
From: Olivier Louvignes <olivier@mg-crea.com>
Date: Sat, 27 May 2017 16:48:20 +0200
Subject: [PATCH] feat(type): add support for new link: dependency type

---
 __tests__/commands/add.js                     | 16 ++++++
 __tests__/commands/install/integration.js     | 56 +++++++++++++++++++
 .../install-file-link-dependencies/.npmrc     |  1 +
 .../install-file-link-dependencies/a/index.js |  1 +
 .../a/package.json                            |  7 +++
 .../install-file-link-dependencies/b/index.js |  1 +
 .../b/package.json                            |  4 ++
 .../package.json                              |  7 +++
 .../install/install-link/bar/index.js         |  1 +
 .../install/install-link/bar/package.json     |  5 ++
 .../install/install-link/package.json         |  7 +++
 src/cli/commands/check.js                     | 23 +++++++-
 src/cli/commands/install.js                   |  6 +-
 src/config.js                                 |  3 +
 src/package-fetcher.js                        |  7 +++
 src/package-linker.js                         | 14 +++--
 src/resolvers/exotics/file-resolver.js        | 14 +++++
 src/resolvers/exotics/link-resolver.js        | 46 +++++++++++++++
 src/resolvers/index.js                        |  2 +
 src/util/fs.js                                | 15 ++++-
 20 files changed, 228 insertions(+), 8 deletions(-)
 create mode 100644 __tests__/fixtures/install/install-file-link-dependencies/.npmrc
 create mode 100644 __tests__/fixtures/install/install-file-link-dependencies/a/index.js
 create mode 100644 __tests__/fixtures/install/install-file-link-dependencies/a/package.json
 create mode 100644 __tests__/fixtures/install/install-file-link-dependencies/b/index.js
 create mode 100644 __tests__/fixtures/install/install-file-link-dependencies/b/package.json
 create mode 100644 __tests__/fixtures/install/install-file-link-dependencies/package.json
 create mode 100644 __tests__/fixtures/install/install-link/bar/index.js
 create mode 100644 __tests__/fixtures/install/install-link/bar/package.json
 create mode 100644 __tests__/fixtures/install/install-link/package.json
 create mode 100644 src/resolvers/exotics/link-resolver.js

diff --git a/__tests__/commands/add.js b/__tests__/commands/add.js
index af48e5682f..9da59ffa4b 100644
--- a/__tests__/commands/add.js
+++ b/__tests__/commands/add.js
@@ -109,6 +109,22 @@ test.concurrent('install with --optional flag', (): Promise<void> => {
   });
 });
 
+test.concurrent('install with link: specifier', (): Promise<void> => {
+  return runAdd(['link:../left-pad'], {dev: true}, 'add-with-flag', async config => {
+    const lockfile = explodeLockfile(await fs.readFile(path.join(config.cwd, 'yarn.lock')));
+    const pkg = await fs.readJson(path.join(config.cwd, 'package.json'));
+
+    const expectPath = path.join(config.cwd, 'node_modules', 'left-pad');
+
+    const stat = await fs.lstat(expectPath);
+    expect(stat.isSymbolicLink()).toEqual(true);
+
+    expect(lockfile.indexOf('left-pad@1.1.0:')).toEqual(-1);
+    expect(pkg.devDependencies).toEqual({'left-pad': 'link:../left-pad'});
+    expect(pkg.dependencies).toEqual({});
+  });
+});
+
 test.concurrent('install with arg that has binaries', (): Promise<void> => {
   return runAdd(['react-native-cli'], {}, 'install-with-arg-and-bin');
 });
diff --git a/__tests__/commands/install/integration.js b/__tests__/commands/install/integration.js
index 2099634222..7dd8db9604 100644
--- a/__tests__/commands/install/integration.js
+++ b/__tests__/commands/install/integration.js
@@ -61,6 +61,45 @@ test.concurrent('properly find and save build artifacts', async () => {
   });
 });
 
+test.concurrent('creates a symlink to a directory when using the link: protocol', async () => {
+  await runInstall({}, 'install-link', async (config): Promise<void> => {
+    const expectPath = path.join(config.cwd, 'node_modules', 'test-absolute');
+
+    const stat = await fs.lstat(expectPath);
+    expect(stat.isSymbolicLink()).toEqual(true);
+
+    const target = await fs.readlink(expectPath);
+    expect(path.resolve(config.cwd, target)).toMatch(/[\\\/]bar$/);
+  });
+});
+
+test.concurrent('creates a symlink to a non-existing directory when using the link: protocol', async () => {
+  await runInstall({}, 'install-link', async (config): Promise<void> => {
+    const expectPath = path.join(config.cwd, 'node_modules', 'test-missing');
+
+    const stat = await fs.lstat(expectPath);
+    expect(stat.isSymbolicLink()).toEqual(true);
+
+    const target = await fs.readlink(expectPath);
+    expect(target).toEqual('../baz');
+  });
+});
+
+test.concurrent(
+  'resolves the symlinks relative to the package path when using the link: protocol; not the node_modules',
+  async () => {
+    await runInstall({}, 'install-link', async (config): Promise<void> => {
+      const expectPath = path.join(config.cwd, 'node_modules', 'test-relative');
+
+      const stat = await fs.lstat(expectPath);
+      expect(stat.isSymbolicLink()).toEqual(true);
+
+      const target = await fs.readlink(expectPath);
+      expect(target).toEqual('../bar');
+    });
+  },
+);
+
 test('changes the cache path when bumping the cache version', async () => {
   await runInstall({}, 'install-github', async (config): Promise<void> => {
     const inOut = new stream.PassThrough();
@@ -263,6 +302,23 @@ test.concurrent('install file: local packages with local dependencies', async ()
   });
 });
 
+test.concurrent('install file: link file dependencies', async (): Promise<void> => {
+  await runInstall({}, 'install-file-link-dependencies', async (config, reporter) => {
+    const statA = await fs.lstat(path.join(config.cwd, 'node_modules', 'a'));
+    expect(statA.isSymbolicLink()).toEqual(true);
+
+    const statB = await fs.lstat(path.join(config.cwd, 'node_modules', 'b'));
+    expect(statB.isSymbolicLink()).toEqual(true);
+
+    const statC = await fs.lstat(path.join(config.cwd, 'node_modules', 'c'));
+    expect(statC.isSymbolicLink()).toEqual(true);
+
+    expect(await fs.readFile(path.join(config.cwd, 'node_modules', 'a', 'index.js'))).toEqual('foo;\n');
+
+    expect(await fs.readFile(path.join(config.cwd, 'node_modules', 'b', 'index.js'))).toEqual('bar;\n');
+  });
+});
+
 test.concurrent('install file: protocol', (): Promise<void> => {
   return runInstall({noLockfile: true}, 'install-file', async config => {
     expect(await fs.readFile(path.join(config.cwd, 'node_modules', 'foo', 'index.js'))).toEqual('foobar;\n');
diff --git a/__tests__/fixtures/install/install-file-link-dependencies/.npmrc b/__tests__/fixtures/install/install-file-link-dependencies/.npmrc
new file mode 100644
index 0000000000..a1bd8c7dec
--- /dev/null
+++ b/__tests__/fixtures/install/install-file-link-dependencies/.npmrc
@@ -0,0 +1 @@
+yarn-link-file-dependencies=true
diff --git a/__tests__/fixtures/install/install-file-link-dependencies/a/index.js b/__tests__/fixtures/install/install-file-link-dependencies/a/index.js
new file mode 100644
index 0000000000..e901f01b48
--- /dev/null
+++ b/__tests__/fixtures/install/install-file-link-dependencies/a/index.js
@@ -0,0 +1 @@
+foo;
diff --git a/__tests__/fixtures/install/install-file-link-dependencies/a/package.json b/__tests__/fixtures/install/install-file-link-dependencies/a/package.json
new file mode 100644
index 0000000000..b283edff8b
--- /dev/null
+++ b/__tests__/fixtures/install/install-file-link-dependencies/a/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "a",
+  "version": "1.0.2",
+  "dependencies": {
+    "b": "*"
+  }
+}
diff --git a/__tests__/fixtures/install/install-file-link-dependencies/b/index.js b/__tests__/fixtures/install/install-file-link-dependencies/b/index.js
new file mode 100644
index 0000000000..e46160df1c
--- /dev/null
+++ b/__tests__/fixtures/install/install-file-link-dependencies/b/index.js
@@ -0,0 +1 @@
+bar;
diff --git a/__tests__/fixtures/install/install-file-link-dependencies/b/package.json b/__tests__/fixtures/install/install-file-link-dependencies/b/package.json
new file mode 100644
index 0000000000..c2d84cc127
--- /dev/null
+++ b/__tests__/fixtures/install/install-file-link-dependencies/b/package.json
@@ -0,0 +1,4 @@
+{
+  "name": "b",
+  "version": "1.0.0"
+}
diff --git a/__tests__/fixtures/install/install-file-link-dependencies/package.json b/__tests__/fixtures/install/install-file-link-dependencies/package.json
new file mode 100644
index 0000000000..80a9b8a533
--- /dev/null
+++ b/__tests__/fixtures/install/install-file-link-dependencies/package.json
@@ -0,0 +1,7 @@
+{
+  "dependencies": {
+    "a": "file:./a",
+    "b": "file:./b",
+    "c": "file:./c"
+  }
+}
diff --git a/__tests__/fixtures/install/install-link/bar/index.js b/__tests__/fixtures/install/install-link/bar/index.js
new file mode 100644
index 0000000000..6e6da2546d
--- /dev/null
+++ b/__tests__/fixtures/install/install-link/bar/index.js
@@ -0,0 +1 @@
+foobar;
diff --git a/__tests__/fixtures/install/install-link/bar/package.json b/__tests__/fixtures/install/install-link/bar/package.json
new file mode 100644
index 0000000000..f92edb96b8
--- /dev/null
+++ b/__tests__/fixtures/install/install-link/bar/package.json
@@ -0,0 +1,5 @@
+{
+  "name": "bar",
+  "version": "0.0.0",
+  "main": "index.js"
+}
diff --git a/__tests__/fixtures/install/install-link/package.json b/__tests__/fixtures/install/install-link/package.json
new file mode 100644
index 0000000000..59ced77fa9
--- /dev/null
+++ b/__tests__/fixtures/install/install-link/package.json
@@ -0,0 +1,7 @@
+{
+  "dependencies": {
+    "test-absolute": "link:/tmp/bar",
+    "test-relative": "link:bar",
+    "test-missing": "link:baz"
+  }
+}
diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js
index a375441a7d..7a6c1c9f2e 100644
--- a/src/cli/commands/check.js
+++ b/src/cli/commands/check.js
@@ -49,21 +49,33 @@ export async function verifyTreeCheck(
   const dependenciesToCheckVersion: PackageToVerify[] = [];
   if (rootManifest.dependencies) {
     for (const name in rootManifest.dependencies) {
+      const version = rootManifest.dependencies[name];
+      // skip linked dependencies
+      const isLinkedDepencency = /^link:/i.test(version) || (/^file:/i.test(version) && config.linkFileDependencies);
+      if (isLinkedDepencency) {
+        continue;
+      }
       dependenciesToCheckVersion.push({
         name,
         originalKey: name,
         parentCwd: registry.cwd,
-        version: rootManifest.dependencies[name],
+        version,
       });
     }
   }
   if (rootManifest.devDependencies && !config.production) {
     for (const name in rootManifest.devDependencies) {
+      const version = rootManifest.devDependencies[name];
+      // skip linked dependencies
+      const isLinkedDepencency = /^link:/i.test(version) || (/^file:/i.test(version) && config.linkFileDependencies);
+      if (isLinkedDepencency) {
+        continue;
+      }
       dependenciesToCheckVersion.push({
         name,
         originalKey: name,
         parentCwd: registry.cwd,
-        version: rootManifest.devDependencies[name],
+        version,
       });
     }
   }
@@ -252,6 +264,13 @@ export async function run(config: Config, reporter: Reporter, flags: Object, arg
       human = humanParts.join('');
     }
 
+    // skip unnecessary checks for linked dependencies
+    const remoteType = pkg._reference.remote.type;
+    const isLinkedDepencency = remoteType === 'link' || (remoteType === 'file' && config.linkFileDependencies);
+    if (isLinkedDepencency) {
+      continue;
+    }
+
     const pkgLoc = path.join(loc, 'package.json');
     if (!await fs.exists(loc) || !await fs.exists(pkgLoc)) {
       if (pkg._reference.optional) {
diff --git a/src/cli/commands/install.js b/src/cli/commands/install.js
index 06851d3657..354680baa1 100644
--- a/src/cli/commands/install.js
+++ b/src/cli/commands/install.js
@@ -692,7 +692,11 @@ export class Install {
       for (const manifest of this.resolver.getManifests()) {
         const ref = manifest._reference;
         invariant(ref, 'expected reference');
-
+        const {type} = ref.remote;
+        // link specifier won't ever hit cache
+        if (type === 'link') {
+          continue;
+        }
         const loc = this.config.generateHardModulePath(ref);
         const newPkg = await this.config.readManifest(loc);
         await this.resolver.updateManifest(ref, newPkg);
diff --git a/src/config.js b/src/config.js
index a72cca1868..dece44681b 100644
--- a/src/config.js
+++ b/src/config.js
@@ -31,6 +31,7 @@ export type ConfigOptions = {
   preferOffline?: boolean,
   pruneOfflineMirror?: boolean,
   enableMetaFolder?: boolean,
+  linkFileDependencies?: boolean,
   captureHar?: boolean,
   ignoreScripts?: boolean,
   ignorePlatform?: boolean,
@@ -91,6 +92,7 @@ export default class Config {
   preferOffline: boolean;
   pruneOfflineMirror: boolean;
   enableMetaFolder: boolean;
+  linkFileDependencies: boolean;
   disableLockfileVersions: boolean;
   ignorePlatform: boolean;
   binLinks: boolean;
@@ -268,6 +270,7 @@ export default class Config {
 
     this.pruneOfflineMirror = Boolean(this.getOption('yarn-offline-mirror-pruning'));
     this.enableMetaFolder = Boolean(this.getOption('enable-meta-folder'));
+    this.linkFileDependencies = Boolean(this.getOption('yarn-link-file-dependencies'));
     this.disableLockfileVersions = Boolean(this.getOption('yarn-disable-lockfile-versions'));
 
     //init & create cacheFolder, tempFolder
diff --git a/src/package-fetcher.js b/src/package-fetcher.js
index 0aca179395..c250b293b2 100644
--- a/src/package-fetcher.js
+++ b/src/package-fetcher.js
@@ -24,6 +24,13 @@ async function fetchOne(ref: PackageReference, config: Config): Promise<FetchedM
   const dest = config.generateHardModulePath(ref);
 
   const remote = ref.remote;
+
+  // Mock metedata for linked dependencies
+  if (remote.type === 'link') {
+    const mockPkg: Manifest = {_uid: '', name: '', version: '0.0.0'};
+    return Promise.resolve({resolved: null, hash: '', dest, package: mockPkg, cached: false});
+  }
+
   const Fetcher = fetchers[remote.type];
   if (!Fetcher) {
     throw new MessageError(config.reporter.lang('unknownFetcherFor', remote.type));
diff --git a/src/package-linker.js b/src/package-linker.js
index 23aa6a5c37..f5a9480bbc 100644
--- a/src/package-linker.js
+++ b/src/package-linker.js
@@ -140,15 +140,20 @@ export default class PackageLinker {
     const hardlinksEnabled = linkDuplicates && (await fs.hardlinksWork(this.config.cwd));
 
     const copiedSrcs: Map<string, string> = new Map();
-    for (const [dest, {pkg, loc: src}] of flatTree) {
+    for (const [dest, {pkg, loc}] of flatTree) {
+      const remote = pkg._remote || {type: ''};
       const ref = pkg._reference;
+      const src = remote.type === 'link' ? remote.reference : loc;
       invariant(ref, 'expected package reference');
       ref.setLocation(dest);
 
       // backwards compatibility: get build artifacts from metadata
-      const metadata = await this.config.readPackageMetadata(src);
-      for (const file of metadata.artifacts) {
-        artifactFiles.push(path.join(dest, file));
+      // does not apply to linked dependencies
+      if (remote.type !== 'link') {
+        const metadata = await this.config.readPackageMetadata(src);
+        for (const file of metadata.artifacts) {
+          artifactFiles.push(path.join(dest, file));
+        }
       }
 
       const integrityArtifacts = this.artifacts[`${pkg.name}@${pkg.version}`];
@@ -166,6 +171,7 @@ export default class PackageLinker {
         copyQueue.set(dest, {
           src,
           dest,
+          type: remote.type,
           onFresh() {
             if (ref) {
               ref.setFresh(true);
diff --git a/src/resolvers/exotics/file-resolver.js b/src/resolvers/exotics/file-resolver.js
index 861b3242c6..cb4c00c858 100644
--- a/src/resolvers/exotics/file-resolver.js
+++ b/src/resolvers/exotics/file-resolver.js
@@ -2,6 +2,7 @@
 
 import type {Manifest} from '../../types.js';
 import type PackageRequest from '../../package-request.js';
+import type {RegistryNames} from '../../registries/index.js';
 import {MessageError} from '../../errors.js';
 import ExoticResolver from './exotic-resolver.js';
 import * as util from '../../util/misc.js';
@@ -30,6 +31,19 @@ export default class FileResolver extends ExoticResolver {
     if (!path.isAbsolute(loc)) {
       loc = path.join(this.config.cwd, loc);
     }
+
+    if (this.config.linkFileDependencies) {
+      const registry: RegistryNames = 'npm';
+      const manifest: Manifest = {_uid: '', name: '', version: '0.0.0', _registry: registry};
+      manifest._remote = {
+        type: 'link',
+        registry,
+        hash: null,
+        reference: loc,
+      };
+      manifest._uid = manifest.version;
+      return manifest;
+    }
     if (!await fs.exists(loc)) {
       throw new MessageError(this.reporter.lang('doesntExist', loc));
     }
diff --git a/src/resolvers/exotics/link-resolver.js b/src/resolvers/exotics/link-resolver.js
new file mode 100644
index 0000000000..524a4a603b
--- /dev/null
+++ b/src/resolvers/exotics/link-resolver.js
@@ -0,0 +1,46 @@
+/* @flow */
+
+import type {Manifest} from '../../types.js';
+import type {RegistryNames} from '../../registries/index.js';
+import type PackageRequest from '../../package-request.js';
+import ExoticResolver from './exotic-resolver.js';
+import * as util from '../../util/misc.js';
+import * as fs from '../../util/fs.js';
+
+const path = require('path');
+
+export default class LinkResolver extends ExoticResolver {
+  constructor(request: PackageRequest, fragment: string) {
+    super(request, fragment);
+    this.loc = util.removePrefix(fragment, 'link:');
+  }
+
+  loc: string;
+
+  static protocol = 'link';
+
+  async resolve(): Promise<Manifest> {
+    let loc = this.loc;
+    if (!path.isAbsolute(loc)) {
+      loc = path.join(this.config.cwd, loc);
+    }
+
+    const name = path.basename(loc);
+    const registry: RegistryNames = 'npm';
+
+    const manifest: Manifest = !await fs.exists(loc)
+      ? {_uid: '', name, version: '0.0.0', _registry: registry}
+      : await this.config.readManifest(loc, this.registry);
+
+    manifest._remote = {
+      type: 'link',
+      registry,
+      hash: null,
+      reference: loc,
+    };
+
+    manifest._uid = manifest.version;
+
+    return manifest;
+  }
+}
diff --git a/src/resolvers/index.js b/src/resolvers/index.js
index 1d5912ee71..5b11b5b617 100644
--- a/src/resolvers/index.js
+++ b/src/resolvers/index.js
@@ -14,6 +14,7 @@ import ExoticGit from './exotics/git-resolver.js';
 import ExoticTarball from './exotics/tarball-resolver.js';
 import ExoticGitHub from './exotics/github-resolver.js';
 import ExoticFile from './exotics/file-resolver.js';
+import ExoticLink from './exotics/link-resolver.js';
 import ExoticGitLab from './exotics/gitlab-resolver.js';
 import ExoticGist from './exotics/gist-resolver.js';
 import ExoticBitbucket from './exotics/bitbucket-resolver.js';
@@ -23,6 +24,7 @@ export const exotics = {
   tarball: ExoticTarball,
   github: ExoticGitHub,
   file: ExoticFile,
+  link: ExoticLink,
   gitlab: ExoticGitLab,
   gist: ExoticGist,
   bitbucket: ExoticBitbucket,
diff --git a/src/util/fs.js b/src/util/fs.js
index 3810708d78..e5b552380c 100644
--- a/src/util/fs.js
+++ b/src/util/fs.js
@@ -42,6 +42,7 @@ const noop = () => {};
 export type CopyQueueItem = {
   src: string,
   dest: string,
+  type?: string,
   onFresh?: ?() => void,
   onDone?: ?() => void,
 };
@@ -159,11 +160,23 @@ async function buildActionsForCopy(
 
   //
   async function build(data): Promise<void> {
-    const {src, dest} = data;
+    const {src, dest, type} = data;
     const onFresh = data.onFresh || noop;
     const onDone = data.onDone || noop;
     files.add(dest);
 
+    if (type === 'link') {
+      await mkdirp(path.dirname(dest));
+      onFresh();
+      actions.push({
+        type: 'symlink',
+        dest,
+        linkname: src,
+      });
+      onDone();
+      return;
+    }
+
     if (events.ignoreBasenames.indexOf(path.basename(src)) >= 0) {
       // ignored file
       return;