package_json 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # PackageJson
2
+
3
+ The missing gem for managing `package.json` files, without having to know about
4
+ package managers (mostly).
5
+
6
+ It provides an interface for easily modifying the properties of `package.json`
7
+ files, along with a "middle-level" abstraction over JavaScript package mangers
8
+ to make it easy to manage dependencies without needing to know the specifics of
9
+ the underlying package manager (and potentially without even knowing the manager
10
+ itself!).
11
+
12
+ This is _not_ meant to provide the exact same functionality and behaviour
13
+ regardless of what package manager is being used, but rather make it easier to
14
+ perform common general tasks that are supported by all package managers like
15
+ adding new dependencies, installing existing ones, and running scripts without
16
+ having to know the actual command a specific package manager requires for that
17
+ action (and other such nuances).
18
+
19
+ ## Installation
20
+
21
+ Install the gem and add to the application's Gemfile by executing:
22
+
23
+ $ bundle add package_json
24
+
25
+ If bundler is not being used to manage dependencies, install the gem by
26
+ executing:
27
+
28
+ $ gem install package_json
29
+
30
+ ## Usage
31
+
32
+ ```ruby
33
+ # represents $PWD/package.json, creating it if it does not exist
34
+ package_json = PackageJson.new
35
+
36
+ # adds eslint, eslint-plugin-prettier, and prettier as development dependencies
37
+ package_json.manager.add(%w[eslint prettier], :dev)
38
+
39
+ # adds the "lint" and "format" scripts, preserving any existing scripts
40
+ package_json.merge! do |pj|
41
+ {
42
+ "scripts" => pj.fetch("scripts", {}).merge({
43
+ "lint" => "eslint . --ext js",
44
+ "format" => "prettier --check ."
45
+ })
46
+ }
47
+ end
48
+
49
+ # deletes the "babel" property, if it exists
50
+ package_json.delete!("babel")
51
+
52
+ # runs the "lint" script with the "--fix" argument
53
+ package_json.manager.run("lint", ["--fix"])
54
+ ```
55
+
56
+ The `PackageJson` class represents a `package.json` on disk within a directory;
57
+ because it is expected that the `package.json` might be changed by external
58
+ sources such as package managers, `PackageJson` reads and writes to and from the
59
+ `package.json` as needed rather than representing it in memory.
60
+
61
+ If you expect the `package.json` to already exist, you can use `read` instead
62
+ which will raise an error instead of implicitly creating the file if it doesn't
63
+ exist.
64
+
65
+ A `PackageJson` also comes with a `manager` that can be used to manage
66
+ dependencies and run scripts.
67
+
68
+ ### Specifying a package manager
69
+
70
+ You can specify which package manager should be used with the
71
+ [`packageManager`](https://nodejs.org/api/packages.html#packagemanager) property
72
+ in the `package.json`.
73
+
74
+ > **Note**
75
+ >
76
+ > Only the name of the package manager is used; the version (if present) is
77
+ > _not_ checked, nor is [`codepack`](https://nodejs.org/api/corepack.html) used
78
+ > to ensure that the package manager is installed.
79
+ >
80
+ > The manager will be invoked by its name in the directory of the
81
+ > `package.json`, and it is up to the developer to ensure that results in the
82
+ > desired package manager actually running.
83
+
84
+ If the `packageManager` property is not present, then the fallback manager will
85
+ be used; this defaults to the value of the `PACKAGE_JSON_FALLBACK_MANAGER`
86
+ environment variable or otherwise `npm`. You can also provide a specific
87
+ fallback manager:
88
+
89
+ ```ruby
90
+ PackageJson.read(fallback_manager: :pnpm)
91
+ PackageJson.new(fallback_manager: :yarn_classic)
92
+ ```
93
+
94
+ Supported package managers are `:npm`, `:yarn_berry`, `:yarn_classic`, `:pnpm`,
95
+ and `:bun`.
96
+
97
+ If the `package.json` does not exist, then the `packageManager` property will be
98
+ included based on this value, but it will _not_ be updated if the file already
99
+ exists without the property.
100
+
101
+ Managers are provided a reference to the `PackageJson` when they're initialized,
102
+ are run in the same directory as that `PackageJson`.
103
+
104
+ ### Using the package manager
105
+
106
+ Each package manager supports a set of common methods which are covered below.
107
+ Unless otherwise noted for a particular method, each method:
108
+
109
+ - Behaves like `system`, returning either `true`, `false`, or `nil` based on if
110
+ the package manager exited with a non-zero error code; each method has a
111
+ bang-equivalent if you wish an exception to be thrown instead
112
+ - Does not attempt to capture or intercept the output; using `Kernel.system`
113
+ under the hood, output is sent directly to `stdout` and `stderr`
114
+ - Will run in the directory of the `package.json`; for methods that generate
115
+ native commands, it is up to the caller to ensure the working directory is
116
+ correct
117
+
118
+ #### Get the version of the package manager
119
+
120
+ ```ruby
121
+ package_json.manager.version
122
+ ```
123
+
124
+ This is suitable for checking that the package manager is actually available
125
+ before performing other operations. Unlike other non-bang methods, this will
126
+ error if the underlying command exits with a non-zero code.
127
+
128
+ #### Installing dependencies
129
+
130
+ ```ruby
131
+ # install all dependencies
132
+ package_json.manager.install
133
+
134
+ # install all dependencies, erroring if the lockfile is outdated
135
+ package_json.manager.install(frozen: true)
136
+ ```
137
+
138
+ | Option | Description |
139
+ | -------- | ---------------------------------------- |
140
+ | `frozen` | Fail if the lockfile needs to be updated |
141
+
142
+ #### Generating the `install` command for native scripts and advanced calls
143
+
144
+ ```ruby
145
+ # returns an array of strings that make up the desired operation
146
+ native_install_command = package_json.manager.native_install_command
147
+
148
+ # runs the command with extra environment variables
149
+ Kernel.system({ "HELLO" => "WORLD" }, *native_install_command)
150
+
151
+ append_to_file "bin/ci-run" do
152
+ <<~CMD
153
+ echo "* ******************************************************"
154
+ echo "* Installing JS dependencies"
155
+ echo "* ******************************************************"
156
+ #{native_install_command.join(" ")}
157
+ CMD
158
+ end
159
+ ```
160
+
161
+ | Option | Description |
162
+ | -------- | ---------------------------------------- |
163
+ | `frozen` | Fail if the lockfile needs to be updated |
164
+
165
+ #### Adding dependencies
166
+
167
+ ```ruby
168
+ # adds axios as a production dependency
169
+ package_json.manager.add(["axios"])
170
+
171
+ # adds eslint and prettier as dev dependencies
172
+ package_json.manager.add(["eslint", "prettier"], type: :dev)
173
+
174
+ # adds dotenv-webpack v6 as a production dependency
175
+ package_json.manager.add(["dotenv-webpack@^6"])
176
+ ```
177
+
178
+ | Option | Description |
179
+ | ------ | ------------------------------------------------------------------------------------------- |
180
+ | `type` | The type to add the dependencies as; either `:production` (default), `:dev`, or `:optional` |
181
+
182
+ #### Removing dependencies
183
+
184
+ ```ruby
185
+ # removes the axios package
186
+ package_json.manager.remove(["axios"])
187
+ ```
188
+
189
+ #### Run a script
190
+
191
+ ```ruby
192
+ # runs the "test" script
193
+ package_json.manager.run("test")
194
+
195
+ # runs the "test" script, passing it "--coverage path/to/my/test.js" as the argument
196
+ package_json.manager.run("test", ["--coverage", "path/to/my/test.js"])
197
+
198
+ # runs the "lint" script, passing it "--fix" as the argument and telling the package manager to be silent
199
+ package_json.manager.run("lint", ["--fix"], silent: true)
200
+ ```
201
+
202
+ | Option | Description |
203
+ | -------- | ---------------------------------------- |
204
+ | `silent` | Suppress output from the package manager |
205
+
206
+ #### Generating a `run` command for native scripts and advanced calls
207
+
208
+ ```ruby
209
+ native_run_command = package_json.manager.native_run_command("test", ["--coverage"])
210
+
211
+ # runs the command with extra environment variables
212
+ Kernel.system({ "HELLO" => "WORLD" }, *native_run_command)
213
+
214
+ append_to_file "bin/ci-run" do
215
+ <<~CMD
216
+ echo "* ******************************************************"
217
+ echo "* Running JS tests"
218
+ echo "* ******************************************************"
219
+ #{native_run_command.join(" ")}
220
+ CMD
221
+ end
222
+ ```
223
+
224
+ | Option | Description |
225
+ | -------- | ---------------------------------------- |
226
+ | `silent` | Suppress output from the package manager |
227
+
228
+ #### Generating a `exec` command for native scripts and advanced calls
229
+
230
+ ```ruby
231
+ native_exec_command = package_json.manager.native_exec_command("webpack", ["serve"])
232
+
233
+ # runs the command with extra environment variables
234
+ Kernel.system({ "HELLO" => "WORLD" }, *native_exec_command)
235
+
236
+ append_to_file "bin/webpack-webpack" do
237
+ <<~CMD
238
+ echo "* ******************************************************"
239
+ echo "* Serving assets via webpack
240
+ echo "* ******************************************************"
241
+ #{native_exec_command.join(" ")}
242
+ CMD
243
+ end
244
+ ```
245
+
246
+ > **Note**
247
+ >
248
+ > Since Yarn Classic doesn't provide a native `exec` command, `yarn bin` is used
249
+ > instead to identify where the package command should be within `node_modules`.
250
+ >
251
+ > For other package managers, their native `exec` command is used with the flags
252
+ > necessary to enforce the package command is only executed if the package is
253
+ > installed locally.
254
+
255
+ ## Development
256
+
257
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
258
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
259
+ prompt that will allow you to experiment.
260
+
261
+ To install this gem onto your local machine, run `bundle exec rake install`. To
262
+ release a new version, update the version number in `version.rb`, and then run
263
+ `bundle exec rake release`, which will create a git tag for the version, push
264
+ git commits and the created tag, and push the `.gem` file to
265
+ [rubygems.org](https://rubygems.org).
266
+
267
+ ## Contributing
268
+
269
+ Bug reports and pull requests are welcome on GitHub at
270
+ https://github.com/[USERNAME]/package_json. This project is intended to be a
271
+ safe, welcoming space for collaboration, and contributors are expected to adhere
272
+ to the
273
+ [code of conduct](https://github.com/[USERNAME]/package_json/blob/main/CODE_OF_CONDUCT.md).
274
+
275
+ ## License
276
+
277
+ The gem is available as open source under the terms of the
278
+ [MIT License](https://opensource.org/licenses/MIT).
279
+
280
+ ## Code of Conduct
281
+
282
+ Everyone interacting in the PackageJson project's codebases, issue trackers,
283
+ chat rooms and mailing lists is expected to follow the
284
+ [code of conduct](https://github.com/[USERNAME]/package_json/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,118 @@
1
+ class PackageJson
2
+ module Managers
3
+ class Base
4
+ # @return [String] the binary to invoke for running the package manager
5
+ attr_reader :binary
6
+
7
+ def initialize(package_json, binary_name:)
8
+ # @type [PackageJson]
9
+ @package_json = package_json
10
+ # @type [String]
11
+ @binary = binary_name
12
+ end
13
+
14
+ def version
15
+ require "open3"
16
+
17
+ command = "#{binary} --version"
18
+ stdout, stderr, status = Open3.capture3(command, chdir: @package_json.directory)
19
+
20
+ unless status.success?
21
+ raise PackageJson::Error, "#{command} failed with exit code #{status.exitstatus}: #{stderr}"
22
+ end
23
+
24
+ stdout.chomp
25
+ end
26
+
27
+ # Installs the dependencies specified in the `package.json` file
28
+ def install(frozen: false)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ # Provides the "native" command for installing dependencies with this package manager for embedding into scripts
33
+ def native_install_command(frozen: false)
34
+ raise NotImplementedError
35
+ end
36
+
37
+ # Installs the dependencies specified in the `package.json` file
38
+ def install!(frozen: false)
39
+ raise_exited_with_non_zero_code_error unless install(frozen: frozen)
40
+ end
41
+
42
+ # Adds the given packages
43
+ def add(packages, type: :production)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # Adds the given packages
48
+ def add!(packages, type: :production)
49
+ raise_exited_with_non_zero_code_error unless add(packages, type: type)
50
+ end
51
+
52
+ # Removes the given packages
53
+ def remove(packages)
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # Removes the given packages
58
+ def remove!(
59
+ packages
60
+ )
61
+ raise_exited_with_non_zero_code_error unless remove(packages)
62
+ end
63
+
64
+ # Runs the script assuming it is defined in the `package.json` file
65
+ def run(
66
+ script_name,
67
+ args = [],
68
+ silent: false
69
+ )
70
+ raise NotImplementedError
71
+ end
72
+
73
+ # Runs the script assuming it is defined in the `package.json` file
74
+ def run!(
75
+ script_name,
76
+ args = [],
77
+ silent: false
78
+ )
79
+ raise_exited_with_non_zero_code_error unless run(
80
+ script_name,
81
+ args,
82
+ silent: silent
83
+ )
84
+ end
85
+
86
+ # Provides the "native" command for running the script with args for embedding into shell scripts
87
+ def native_run_command(
88
+ script_name,
89
+ args = [],
90
+ silent: false
91
+ )
92
+ raise NotImplementedError
93
+ end
94
+
95
+ # Provides the "native" command for executing a package with args for embedding into shell scripts
96
+ def native_exec_command(
97
+ script_name,
98
+ args = []
99
+ )
100
+ raise NotImplementedError
101
+ end
102
+
103
+ private
104
+
105
+ def raise_exited_with_non_zero_code_error
106
+ raise Error, "#{binary} exited with non-zero code"
107
+ end
108
+
109
+ def build_full_cmd(sub_cmd, args)
110
+ [binary, sub_cmd, *args]
111
+ end
112
+
113
+ def raw(sub_cmd, args)
114
+ Kernel.system(*build_full_cmd(sub_cmd, args), chdir: @package_json.directory)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,79 @@
1
+ class PackageJson
2
+ module Managers
3
+ class BunLike < Base
4
+ def initialize(package_json)
5
+ super(package_json, binary_name: "bun")
6
+ end
7
+
8
+ # Installs the dependencies specified in the `package.json` file
9
+ def install(frozen: false)
10
+ raw("install", with_frozen_flag(frozen))
11
+ end
12
+
13
+ # Provides the "native" command for installing dependencies with this package manager for embedding into scripts
14
+ def native_install_command(frozen: false)
15
+ build_full_cmd("install", with_frozen_flag(frozen))
16
+ end
17
+
18
+ # Adds the given packages
19
+ def add(packages, type: :production)
20
+ raw("add", [package_type_install_flag(type)].compact + packages)
21
+ end
22
+
23
+ # Removes the given packages
24
+ def remove(packages)
25
+ raw("remove", packages)
26
+ end
27
+
28
+ # Runs the script assuming it is defined in the `package.json` file
29
+ def run(
30
+ script_name,
31
+ args = [],
32
+ silent: false
33
+ )
34
+ raw("run", build_run_args(script_name, args, _silent: silent))
35
+ end
36
+
37
+ # Provides the "native" command for running the script with args for embedding into shell scripts
38
+ def native_run_command(
39
+ script_name,
40
+ args = [],
41
+ silent: false
42
+ )
43
+ build_full_cmd("run", build_run_args(script_name, args, _silent: silent))
44
+ end
45
+
46
+ def native_exec_command(
47
+ script_name,
48
+ args = []
49
+ )
50
+ build_full_cmd("run", build_run_args(script_name, args, _silent: false))
51
+ end
52
+
53
+ private
54
+
55
+ def build_run_args(script_name, args, _silent:)
56
+ [script_name, *args]
57
+ end
58
+
59
+ def with_frozen_flag(frozen)
60
+ return ["--frozen-lockfile"] if frozen
61
+
62
+ []
63
+ end
64
+
65
+ def package_type_install_flag(type)
66
+ case type
67
+ when :production
68
+ nil
69
+ when :dev
70
+ "--dev"
71
+ when :optional
72
+ "--optional"
73
+ else
74
+ raise Error, "unsupported package install type \"#{type}\""
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,83 @@
1
+ class PackageJson
2
+ module Managers
3
+ class NpmLike < Base
4
+ def initialize(package_json)
5
+ super(package_json, binary_name: "npm")
6
+ end
7
+
8
+ # Installs the dependencies specified in the `package.json` file
9
+ def install(frozen: false)
10
+ cmd = "install"
11
+ cmd = "ci" if frozen
12
+
13
+ raw(cmd, [])
14
+ end
15
+
16
+ # Provides the "native" command for installing dependencies with this package manager for embedding into scripts
17
+ def native_install_command(frozen: false)
18
+ cmd = "install"
19
+ cmd = "ci" if frozen
20
+
21
+ build_full_cmd(cmd, [])
22
+ end
23
+
24
+ # Adds the given packages
25
+ def add(packages, type: :production)
26
+ raw("install", [package_type_install_flag(type)] + packages)
27
+ end
28
+
29
+ # Removes the given packages
30
+ def remove(packages)
31
+ raw("remove", packages)
32
+ end
33
+
34
+ # Runs the script assuming it is defined in the `package.json` file
35
+ def run(
36
+ script_name,
37
+ args = [],
38
+ silent: false
39
+ )
40
+ raw("run", build_run_args(script_name, args, silent: silent))
41
+ end
42
+
43
+ # Provides the "native" command for running the script with args for embedding into shell scripts
44
+ def native_run_command(
45
+ script_name,
46
+ args = [],
47
+ silent: false
48
+ )
49
+ build_full_cmd("run", build_run_args(script_name, args, silent: silent))
50
+ end
51
+
52
+ def native_exec_command(
53
+ script_name,
54
+ args = []
55
+ )
56
+ build_full_cmd("exec", ["--no", "--offline"] + build_run_args(script_name, args, silent: false))
57
+ end
58
+
59
+ private
60
+
61
+ def build_run_args(script_name, args, silent:)
62
+ # npm assumes flags prefixed with - are for it, unless they come after a "--"
63
+ args = [script_name, "--", *args]
64
+
65
+ args.unshift("--silent") if silent
66
+ args
67
+ end
68
+
69
+ def package_type_install_flag(type)
70
+ case type
71
+ when :production
72
+ "--save-prod"
73
+ when :dev
74
+ "--save-dev"
75
+ when :optional
76
+ "--save-optional"
77
+ else
78
+ raise Error, "unsupported package install type \"#{type}\""
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,84 @@
1
+ class PackageJson
2
+ module Managers
3
+ class PnpmLike < Base
4
+ def initialize(package_json)
5
+ super(package_json, binary_name: "pnpm")
6
+ end
7
+
8
+ # Installs the dependencies specified in the `package.json` file
9
+ def install(frozen: false)
10
+ raw("install", with_frozen_flag(frozen))
11
+ end
12
+
13
+ # Provides the "native" command for installing dependencies with this package manager for embedding into scripts
14
+ def native_install_command(frozen: false)
15
+ build_full_cmd("install", with_frozen_flag(frozen))
16
+ end
17
+
18
+ # Adds the given packages
19
+ def add(packages, type: :production)
20
+ raw("add", [package_type_install_flag(type)] + packages)
21
+ end
22
+
23
+ # Removes the given packages
24
+ def remove(packages)
25
+ raw("remove", packages)
26
+ end
27
+
28
+ # Runs the script assuming it is defined in the `package.json` file
29
+ def run(
30
+ script_name,
31
+ args = [],
32
+ silent: false
33
+ )
34
+ raw("run", build_run_args(script_name, args, silent: silent))
35
+ end
36
+
37
+ # Provides the "native" command for running the script with args for embedding into shell scripts
38
+ def native_run_command(
39
+ script_name,
40
+ args = [],
41
+ silent: false
42
+ )
43
+ build_full_cmd("run", build_run_args(script_name, args, silent: silent))
44
+ end
45
+
46
+ def native_exec_command(
47
+ script_name,
48
+ args = []
49
+ )
50
+ build_full_cmd("exec", build_run_args(script_name, args, silent: false))
51
+ end
52
+
53
+ private
54
+
55
+ def build_run_args(script_name, args, silent:)
56
+ args = [script_name, *args]
57
+
58
+ args.unshift("--silent") if silent
59
+ args
60
+ end
61
+
62
+ def with_frozen_flag(frozen)
63
+ return ["--frozen-lockfile"] if frozen
64
+
65
+ # we make frozen lockfile behaviour consistent with the other package managers
66
+ # as pnpm automatically enables frozen lockfile if it detects it's running in CI
67
+ ["--no-frozen-lockfile"]
68
+ end
69
+
70
+ def package_type_install_flag(type)
71
+ case type
72
+ when :production
73
+ "--save-prod"
74
+ when :dev
75
+ "--save-dev"
76
+ when :optional
77
+ "--save-optional"
78
+ else
79
+ raise Error, "unsupported package install type \"#{type}\""
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end