package_json 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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