From 90acdde97d2ff5230bfadfc84d08e238d4b10a7b Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Sat, 11 May 2019 21:18:52 +0200 Subject: [PATCH] feat(link): generate shims for missing 'bin' scripts (#2059) During the 'link' step, if a package's binary targets don't exist yet they will fail to be symlinked, even if a later `run xxx` step would have generated them. This is a situation that occurs in monorepos containing build tools compiled from another language (e.g. TypeScript). Simply adding the symlinks whether the target exists or not is not good enough, as the symlink target needs to be `chmod +x`ed, which is lerna's/npm's responsibility. Instead, if the target doesn't exist, generate a shim shell script that will `chmod` and `exec` the intended target, similar to what would happen on Windows normally. Fixes #1444 --- .../lerna-generated-build-tool/.gitignore | 1 + .../lerna-generated-build-tool/lerna.json | 3 +++ .../lerna-generated-build-tool/package.json | 7 ++++++ .../packages/build-tool/package.json | 10 ++++++++ .../packages/buildable/package.json | 10 ++++++++ integration/lerna-link-sibling-bins.test.js | 16 ++++++++++++ .../__tests__/create-symlink.test.js | 1 + utils/create-symlink/create-symlink.js | 25 +++++++++++++++++-- .../__tests__/symlink-binary.test.js | 6 ++--- utils/symlink-binary/symlink-binary.js | 12 ++++----- 10 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 integration/__fixtures__/lerna-generated-build-tool/.gitignore create mode 100644 integration/__fixtures__/lerna-generated-build-tool/lerna.json create mode 100644 integration/__fixtures__/lerna-generated-build-tool/package.json create mode 100644 integration/__fixtures__/lerna-generated-build-tool/packages/build-tool/package.json create mode 100644 integration/__fixtures__/lerna-generated-build-tool/packages/buildable/package.json create mode 100644 integration/lerna-link-sibling-bins.test.js diff --git a/integration/__fixtures__/lerna-generated-build-tool/.gitignore b/integration/__fixtures__/lerna-generated-build-tool/.gitignore new file mode 100644 index 0000000000..4ba2db8253 --- /dev/null +++ b/integration/__fixtures__/lerna-generated-build-tool/.gitignore @@ -0,0 +1 @@ +a-build-tool.js diff --git a/integration/__fixtures__/lerna-generated-build-tool/lerna.json b/integration/__fixtures__/lerna-generated-build-tool/lerna.json new file mode 100644 index 0000000000..1587a66968 --- /dev/null +++ b/integration/__fixtures__/lerna-generated-build-tool/lerna.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} diff --git a/integration/__fixtures__/lerna-generated-build-tool/package.json b/integration/__fixtures__/lerna-generated-build-tool/package.json new file mode 100644 index 0000000000..3b59021096 --- /dev/null +++ b/integration/__fixtures__/lerna-generated-build-tool/package.json @@ -0,0 +1,7 @@ +{ + "name": "basic", + "version": "monorepo", + "private": true, + "dependencies": { + } +} diff --git a/integration/__fixtures__/lerna-generated-build-tool/packages/build-tool/package.json b/integration/__fixtures__/lerna-generated-build-tool/packages/build-tool/package.json new file mode 100644 index 0000000000..5f8710f055 --- /dev/null +++ b/integration/__fixtures__/lerna-generated-build-tool/packages/build-tool/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/build-tool", + "version": "1.0.0", + "bin": { + "a-build-tool": "a-build-tool.js" + }, + "scripts": { + "build": "echo \"#!/usr/bin/env node\nconsole.log('build tool executed')\" > a-build-tool.js" + } +} diff --git a/integration/__fixtures__/lerna-generated-build-tool/packages/buildable/package.json b/integration/__fixtures__/lerna-generated-build-tool/packages/buildable/package.json new file mode 100644 index 0000000000..2de1dc04d9 --- /dev/null +++ b/integration/__fixtures__/lerna-generated-build-tool/packages/buildable/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/buildable", + "version": "2.0.0", + "devDependencies": { + "@test/build-tool": "*" + }, + "scripts": { + "build": "a-build-tool" + } +} diff --git a/integration/lerna-link-sibling-bins.test.js b/integration/lerna-link-sibling-bins.test.js new file mode 100644 index 0000000000..20e6c86e30 --- /dev/null +++ b/integration/lerna-link-sibling-bins.test.js @@ -0,0 +1,16 @@ +"use strict"; + +const cliRunner = require("@lerna-test/cli-runner"); +const initFixture = require("@lerna-test/init-fixture")(__dirname); + +test("lerna link symlinks generated binaries of sibling packages", async () => { + const cwd = await initFixture("lerna-generated-build-tool"); + const lerna = cliRunner(cwd); + + // First bootstrap, I expect this to succeed but don't are about the output + await lerna("bootstrap"); + + const { stdout } = await lerna("run", "build"); + + expect(stdout).toMatch("build tool executed"); +}); diff --git a/utils/create-symlink/__tests__/create-symlink.test.js b/utils/create-symlink/__tests__/create-symlink.test.js index ab6f1bfe0c..79663ad327 100644 --- a/utils/create-symlink/__tests__/create-symlink.test.js +++ b/utils/create-symlink/__tests__/create-symlink.test.js @@ -15,6 +15,7 @@ describe("create-symlink", () => { fs.lstat.mockImplementation(() => Promise.reject(new Error("MOCK"))); fs.unlink.mockResolvedValue(); fs.symlink.mockResolvedValue(); + fs.pathExists.mockResolvedValue(true); // cmdShim is a traditional errback cmdShim.mockImplementation(callsBack()); diff --git a/utils/create-symlink/create-symlink.js b/utils/create-symlink/create-symlink.js index 11659c88b7..77ff2fe8cf 100644 --- a/utils/create-symlink/create-symlink.js +++ b/utils/create-symlink/create-symlink.js @@ -31,9 +31,20 @@ function createSymbolicLink(src, dest, type) { function createPosixSymlink(origin, dest, _type) { const type = _type === "exec" ? "file" : _type; - const src = path.relative(path.dirname(dest), origin); + const relativeSymlink = path.relative(path.dirname(dest), origin); - return createSymbolicLink(src, dest, type); + if (_type === "exec") { + // If the target exists, create real symlink. If the target doesn't exist yet, + // create a shim shell script. + return fs.pathExists(origin).then(exists => { + if (exists) { + return createSymbolicLink(relativeSymlink, dest, type).then(() => fs.chmod(origin, "755")); + } + return shShim(origin, dest, type).then(() => fs.chmod(dest, "755")); + }); + } + + return createSymbolicLink(relativeSymlink, dest, type); } function createWindowsSymlink(src, dest, type) { @@ -51,3 +62,13 @@ function createWindowsSymlink(src, dest, type) { return createSymbolicLink(src, dest, type); } + +function shShim(target, script, type) { + log.silly("shShim", [target, script, type]); + + const absTarget = path.resolve(path.dirname(script), target); + + const scriptLines = ["#!/bin/sh", `chmod +x ${absTarget} && exec ${absTarget} "$@"`]; + + return fs.writeFile(script, scriptLines.join("\n")); +} diff --git a/utils/symlink-binary/__tests__/symlink-binary.test.js b/utils/symlink-binary/__tests__/symlink-binary.test.js index 7e7cc1144a..252ee75e65 100644 --- a/utils/symlink-binary/__tests__/symlink-binary.test.js +++ b/utils/symlink-binary/__tests__/symlink-binary.test.js @@ -42,7 +42,7 @@ describe("symlink-binary", () => { expect(dstPath).not.toHaveBinaryLinks(); }); - it("should skip missing bin files", async () => { + it("should create shims for all declared binaries", async () => { const testDir = await initFixture("links"); const srcPath = path.join(testDir, "packages/package-3"); const dstPath = path.join(testDir, "packages/package-4"); @@ -50,7 +50,7 @@ describe("symlink-binary", () => { await symlinkBinary(srcPath, dstPath); expect(srcPath).toHaveExecutables("cli1.js", "cli2.js"); - expect(dstPath).toHaveBinaryLinks("links3cli1", "links3cli2"); + expect(dstPath).toHaveBinaryLinks("links3cli1", "links3cli2", "links3cli3"); }); it("should preserve previous bin entries", async () => { @@ -62,6 +62,6 @@ describe("symlink-binary", () => { await symlinkBinary(pkg2Path, destPath); await symlinkBinary(pkg3Path, destPath); - expect(destPath).toHaveBinaryLinks("links-2", "links3cli1", "links3cli2"); + expect(destPath).toHaveBinaryLinks("links-2", "links3cli1", "links3cli2", "links3cli3"); }); }); diff --git a/utils/symlink-binary/symlink-binary.js b/utils/symlink-binary/symlink-binary.js index 31a1633ef4..d6b12bd6e2 100644 --- a/utils/symlink-binary/symlink-binary.js +++ b/utils/symlink-binary/symlink-binary.js @@ -22,11 +22,11 @@ function symlinkBinary(srcPackageRef, destPackageRef) { const src = path.join(srcPackage.location, srcPackage.bin[name]); const dst = path.join(destPackage.binLocation, name); - return fs.pathExists(src).then(exists => { - if (exists) { - return { src, dst }; - } - }); + // Symlink all declared binaries, even if they don't exist (yet). We will + // assume the package author knows what they're doing and that the binaries + // will be generated during a later build phase (potentially source compiled from + // another language). + return { src, dst }; }); if (actions.length === 0) { @@ -36,7 +36,7 @@ function symlinkBinary(srcPackageRef, destPackageRef) { return fs.mkdirp(destPackage.binLocation).then(() => pMap(actions, meta => { if (meta) { - return createSymlink(meta.src, meta.dst, "exec").then(() => fs.chmod(meta.src, "755")); + return createSymlink(meta.src, meta.dst, "exec"); } }) );