Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] language/node: fix prepublish scripts #2820

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
40 changes: 37 additions & 3 deletions Library/Homebrew/language/node.rb
@@ -1,10 +1,44 @@
require "json"

module Language
module Node
def self.npm_cache_config
"cache=#{HOMEBREW_CACHE}/npm_cache"
end

def self.pack_for_installation
def self.node_system(cmd, *args)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to use the formula system method instead of reimplementing it here.

Copy link
Contributor Author

@chrmoritz chrmoritz Jul 13, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but how to do so? Using just system inside a language will call Homebrew/utils.rb system and not Homebrew/formula.rb one.

An alternative implementation of this using util's _system(basically mixing the implementation of safe_system and quite_system) would be:

def self.node_system(cmd, *args)
  ohai "#{cmd} #{args * " "}"
  Homebrew._system(cmd, *args) do
    unless ARGV.verbose?
      $stdout.reopen("/dev/null")
      $stderr.reopen("/dev/null")
    end
  end
  raise ErrorDuringExecution.new(cmd, args) unless $CHILD_STATUS.success?
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And speaking about formula: Is it possible to access the formula's download url inside language/node? With it we could match it against a npm registry url regex and figure the correct way to handle life cycle scripts out on our own without requiring the formula author to set the prepare_require parameter.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formula object can be passed through as an argument or this module could be included inside the formula itself. That'll handle both of these cases.

ohai "#{cmd} #{args * " "}"
if ARGV.verbose?
safe_system(cmd, *args)
else
quiet_system(cmd, *args)
raise ErrorDuringExecution.new(cmd, args) unless $CHILD_STATUS.exitstatus.zero?
end
end

# Read https://gist.github.com/chrmoritz/34e4c4d7779d72b549e2fc41f77c365c
# for a complete overview of the edge cases this method has to handle.
def self.pack_for_installation(prepare_required: false)
# Rewrites the package.json so that npm pack will bundle all deps
# into a self-contained package, so that we avoid installing them a
# second time during the final installation of the package to libexec.
pkg_json = JSON.parse(IO.read("package.json"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be worth producing a nice error message if this file doesn't exist?

Copy link
Contributor Author

@chrmoritz chrmoritz Jul 13, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What error message do you have in mind here?

A package.json should always exist unless someone is calling language/node from a directory, which is not a valid node module. The Error: No such file or directory - package.json from IO is nearly identical to the error from npm pack (npm ERR! code ENOLOCAL: Could not install from "" as it does not contain a package.json file.) someone would get with the current version of language/node when calling it on a non node module folder.

Copy link
Contributor Author

@chrmoritz chrmoritz Jul 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing the error msg with our own custom one would also hide the actual kind of the IO error: package.json doesn't exist (see above), permission error (no read/write permission) ...
Just doing nothing here and throw the original IO error seems to be the best for debugging.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking any error message that's not a raw exception but you've convinced me this is fine as-is.

if pkg_json["dependencies"]
pkg_json["bundledDependencies"] = pkg_json["dependencies"].keys
IO.write("package.json", JSON.pretty_generate(pkg_json))
end

# If `prepare_required` is false we pass `--production` to install all
# production deps (no devDeps) for bundling them into the pack and also
# to prevent the toplevel prepare / prepublish script from being executed
# while still executing all lifecycle scripts for the deps.
# Otherwise we omit `--production` to install all deps (devDeps might be
# required in `prepare`). This already executes the prepare script too,
# so that we can continue to `npm pack` with `--ignore-scripts`.
install_args = local_npm_install_args
install_args << "--production" unless prepare_required
node_system "npm", "install", *install_args

# Homebrew assumes the buildpath/testpath will always be disposable
# and from npm 5.0.0 the logic changed so that when a directory is
# fed to `npm install` only symlinks are created linking back to that
Expand All @@ -30,13 +64,13 @@ def self.setup_npm_environment
end
end

def self.std_npm_install_args(libexec)
def self.std_npm_install_args(libexec, prepare_required: false)
setup_npm_environment
# tell npm to not install .brew_home by adding it to the .npmignore file
# (or creating a new one if no .npmignore file already exists)
open(".npmignore", "a") { |f| f.write("\n.brew_home\n") }

pack = pack_for_installation
pack = pack_for_installation(prepare_required: prepare_required)

# npm install args for global style module format installed into libexec
%W[
Expand Down
36 changes: 33 additions & 3 deletions Library/Homebrew/test/language/node_spec.rb
@@ -1,4 +1,5 @@
require "language/node"
require "json"

describe Language::Node do
describe "#setup_npm_environment" do
Expand All @@ -25,23 +26,52 @@
npm_install_arg = "libexec"
npm_pack_cmd = "npm pack --ignore-scripts"

it "raises error with non zero exitstatus" do
it "raises error with non zero npm pack exitstatus" do
allow(IO).to receive(:read).and_return("{}")
allow(Language::Node).to receive(:node_system).with("npm", "install", "-ddd", "--build-from-source",
"--cache=#{HOMEBREW_CACHE}/npm_cache", "--production") { `true` }
allow(Utils).to receive(:popen_read).with(npm_pack_cmd) { `false` }
expect { subject.std_npm_install_args(npm_install_arg) }.to \
raise_error("npm failed to pack #{Dir.pwd}")
end

it "raises error with empty npm pack output" do
allow(Utils).to receive(:popen_read).with(npm_pack_cmd) { `true` }
allow(IO).to receive(:read).and_return("{}")
allow(Language::Node).to receive(:node_system).with("npm", "install", "-ddd", "--build-from-source",
"--cache=#{HOMEBREW_CACHE}/npm_cache", "--production") { `true` }
allow(Utils).to receive(:popen_read).with(npm_pack_cmd) { `false` }
expect { subject.std_npm_install_args(npm_install_arg) }.to \
raise_error("npm failed to pack #{Dir.pwd}")
end

it "does not raise error with a zero exitstatus" do
it "does not raise error with a zero npm pack exitstatus" do
allow(IO).to receive(:read).and_return("{}")
allow(Language::Node).to receive(:node_system).with("npm", "install", "-ddd", "--build-from-source",
"--cache=#{HOMEBREW_CACHE}/npm_cache", "--production") { `true` }
allow(Utils).to receive(:popen_read).with(npm_pack_cmd) { `echo pack.tgz` }
resp = subject.std_npm_install_args(npm_install_arg)
expect(resp).to include("--prefix=#{npm_install_arg}", "#{Dir.pwd}/pack.tgz")
end

it "does not raise error with prepare_required set" do
allow(IO).to receive(:read).and_return('{"name":"example","dependencies":{"foo":"1.2.3","bar":"4.5.6"}}')
expected_json = '{"name":"example","dependencies":{"foo":"1.2.3","bar":"4.5.6"},'\
'"bundledDependencies":["foo","bar"]}'
expected_json = JSON.pretty_generate(JSON.parse(expected_json))
allow(IO).to receive(:write).with("package.json", expected_json) { `true` }
allow(Language::Node).to receive(:node_system)
.with("npm", "install", "-ddd", "--build-from-source", "--cache=#{HOMEBREW_CACHE}/npm_cache") { `true` }
allow(Utils).to receive(:popen_read).with("npm pack --ignore-scripts") { `echo pack.tgz` }
resp = subject.std_npm_install_args(npm_install_arg, prepare_required: true)
expect(resp).to include("--prefix=#{npm_install_arg}", "#{Dir.pwd}/pack.tgz")
end

it "raises error with non zero local npm install exitstatus" do
allow(IO).to receive(:read).and_return("{}")
allow(Language::Node).to receive(:node_system).and_raise("Failure while executing: npm install -ddd")
expect { subject.std_npm_install_args(npm_install_arg, prepare_required: true) }.to \
raise_error("Failure while executing: npm install -ddd")
end
end

specify "#local_npm_install_args" do
Expand Down
10 changes: 9 additions & 1 deletion docs/Node-for-Formula-Authors.md
Expand Up @@ -72,7 +72,15 @@ This will install your Node module in npm's global module style with a custom pr
bin.install_symlink Dir["#{libexec}/bin/*"]
```

**Note:** Because of a required workaround for `npm@5` calling `npm pack` we currently don't support installing modules (from non-npm registry tarballs), which require a prepublish step (e.g. for transpiling sources). See [Homebrew/brew#2820](https://github.com/Homebrew/brew/pull/2820) for more information.
**Note:** If your Node module requires a prepare or prepublish step (e.g. for transpiling sources) it is recommended to use a npm registry tarball (which is already prepared/ prepublished) as the download url. If this is not possible, you have to pass `prepare_required: true` to `std_npm_install_args` like in:

```ruby
system "npm", "install", *Language::Node.std_npm_install_args(libexec, prepare_required: true)
```

This will run the `prepare` and `prepublish` scripts of your module before we pack the module and it's dependencies internally for installation.

Not passing `prepare_required: true` will prevent the `prepare` and `prepublish` scripts of your top-level module from being executed, but all lifecycle scripts from it's dependencies are still executed as normal.

### Installing module dependencies locally with `local_npm_install_args`

Expand Down