Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ Metrics/AbcSize:
Metrics/ClassLength:
Exclude:
- 'spec/**/*'
- 'lib/package_json.rb'

Naming/MemoizedInstanceVariableName:
EnforcedStyleForLeadingUnderscores: optional
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
## [Unreleased]

### Added

- Automatic package manager detection from lockfiles when `packageManager`
property is not set. Detection priority: `bun.lockb`, `pnpm-lock.yaml`,
`yarn.lock` (with automatic Yarn Berry vs Classic detection),
`package-lock.json`. Falls back to `PACKAGE_JSON_FALLBACK_MANAGER` environment
variable or npm when no lockfile is found
([PR 42](https://github.com/shakacode/package_json/pull/42) by
[justin808](https://github.com/justin808))

## [0.2.0] - 2025-11-06

### Added
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
arm64-darwin-25
x64-mingw-ucrt
x64-mingw32
x86_64-darwin-19
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,18 @@ in the `package.json`.
> `package.json`, and it is up to the developer to ensure that results in the
> desired package manager actually running.

If the `packageManager` property is not present, then the fallback manager will
be used; this defaults to the value of the `PACKAGE_JSON_FALLBACK_MANAGER`
environment variable or otherwise `npm`. You can also provide a specific
fallback manager:
If the `packageManager` property is not present, the gem will automatically
detect which package manager to use by checking for lockfiles in this priority
order:

1. `bun.lockb` - Bun
2. `pnpm-lock.yaml` - pnpm
3. `yarn.lock` - Yarn (Berry or Classic, determined by file format)
4. `package-lock.json` - npm

If no lockfile is found, then the fallback manager will be used; this defaults
to the value of the `PACKAGE_JSON_FALLBACK_MANAGER` environment variable or
otherwise `npm`. You can also provide a specific fallback manager:

```ruby
PackageJson.read(fallback_manager: :pnpm)
Expand Down
47 changes: 46 additions & 1 deletion lib/package_json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class Error < StandardError; end

class NotImplementedError < Error; end

# Number of bytes to read from lockfile for version detection
# Provides good coverage even with large initial comments
LOCKFILE_DETECTION_READ_SIZE = 1000

attr_reader :manager, :directory

def self.fetch_default_fallback_manager
Expand Down Expand Up @@ -75,15 +79,56 @@ def record_package_manager!
def determine_package_manager(fallback_manager)
package_manager = fetch("packageManager", nil)

return fallback_manager if package_manager.nil?
return parse_package_manager(package_manager) unless package_manager.nil?

# If no packageManager property, check for lockfiles
lockfile_manager = detect_manager_from_lockfile

return lockfile_manager unless lockfile_manager.nil?

# Fall back to the provided fallback manager
fallback_manager
end

def parse_package_manager(package_manager)
name, version = package_manager.split("@")

return determine_yarn_version(version) if name == "yarn"

name.to_sym
end

def detect_manager_from_lockfile
# Check for lockfiles in priority order
return :bun if File.exist?("#{directory}/bun.lockb")
return :pnpm if File.exist?("#{directory}/pnpm-lock.yaml")
return detect_yarn_version_from_lockfile if File.exist?("#{directory}/yarn.lock")
return :npm if File.exist?("#{directory}/package-lock.json")

nil
end

def detect_yarn_version_from_lockfile
lockfile_path = "#{directory}/yarn.lock"

# Check file exists to avoid race condition
return :yarn_classic unless File.exist?(lockfile_path)

# Read the first chunk of bytes to determine the version
# Yarn Berry lockfiles start with "__metadata:" within the first few lines
# Yarn Classic lockfiles use the older format without __metadata:
content = File.read(lockfile_path, LOCKFILE_DETECTION_READ_SIZE)

# Yarn Berry uses __metadata: at the start
return :yarn_berry if content.include?("__metadata:")

# Default to Yarn Classic for older format
:yarn_classic
rescue StandardError
# On error (e.g., corrupted lockfile), default to Yarn Classic
:yarn_classic
end

def determine_yarn_version(version)
raise Error, "a major version must be present for Yarn" if version.nil? || version.empty?

Expand Down
8 changes: 8 additions & 0 deletions sig/package_json.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ class PackageJson

def determine_package_manager: (Symbol fallback_manager) -> Symbol

def parse_package_manager: (String package_manager) -> Symbol

def detect_manager_from_lockfile: () -> Symbol?

def detect_yarn_version_from_lockfile: () -> Symbol

def determine_yarn_version: (String version) -> Symbol

def new_package_manager: (Symbol package_manager_name) -> Managers::Base

def package_json_path: () -> String
Expand Down
174 changes: 172 additions & 2 deletions spec/package_json_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,115 @@
end
end

it "uses the fallback manager" do
it "uses the fallback manager when no lockfile is present" do
with_package_json_file({ "version" => "1.0.0" }) do
package_json = described_class.read(Dir.pwd, fallback_manager: :yarn_classic)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "detects npm from package-lock.json" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("package-lock.json", "{}")
package_json = described_class.read(Dir.pwd, fallback_manager: :yarn_classic)

expect(package_json.manager).to be_a PackageJson::Managers::NpmLike
end
end

it "detects pnpm from pnpm-lock.yaml" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("pnpm-lock.yaml", "lockfileVersion: '6.0'")
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::PnpmLike
end
end

it "detects bun from bun.lockb" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("bun.lockb", "")
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::BunLike
end
end

it "detects yarn classic from yarn.lock without __metadata:" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "# yarn lockfile v1\n\npackage@^1.0.0:\n version \"1.0.0\"")
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "detects yarn berry from yarn.lock with __metadata:" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "__metadata:\n version: 6\n cacheKey: 8")
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnBerryLike
end
end

it "defaults to yarn classic if yarn.lock is unreadable" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "")
allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with("#{Dir.pwd}/yarn.lock",
PackageJson::LOCKFILE_DETECTION_READ_SIZE).and_raise(StandardError)
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "defaults to yarn classic if yarn.lock disappears before reading (race condition)" do
with_package_json_file({ "version" => "1.0.0" }) do
# Simulate race condition: file exists during initial check but not when reading version
lockfile_path = "#{Dir.pwd}/yarn.lock"
allow(File).to receive(:exist?).and_call_original
allow(File).to receive(:exist?).with(lockfile_path).and_return(true, false)
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "prioritizes bun.lockb over other lockfiles" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("bun.lockb", "")
File.write("package-lock.json", "{}")
File.write("yarn.lock", "# yarn lockfile v1")
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::BunLike
end
end

it "prioritizes pnpm-lock.yaml over yarn.lock and package-lock.json" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("pnpm-lock.yaml", "lockfileVersion: '6.0'")
File.write("package-lock.json", "{}")
File.write("yarn.lock", "# yarn lockfile v1")
package_json = described_class.read(Dir.pwd, fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::PnpmLike
end
end

it "prioritizes yarn.lock over package-lock.json" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "# yarn lockfile v1")
File.write("package-lock.json", "{}")
package_json = described_class.read(Dir.pwd, fallback_manager: :bun)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "does not add the packageManager property" do
with_package_json_file({ "version" => "1.0.0" }) do
described_class.read(Dir.pwd, fallback_manager: :yarn_classic)
Expand Down Expand Up @@ -306,14 +407,83 @@
end
end

it "uses the fallback manager" do
it "uses the fallback manager when no lockfile is present" do
with_package_json_file({ "version" => "1.0.0" }) do
package_json = described_class.new(fallback_manager: :yarn_classic)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "detects npm from package-lock.json" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("package-lock.json", "{}")
package_json = described_class.new(fallback_manager: :yarn_classic)

expect(package_json.manager).to be_a PackageJson::Managers::NpmLike
end
end

it "detects pnpm from pnpm-lock.yaml" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("pnpm-lock.yaml", "lockfileVersion: '6.0'")
package_json = described_class.new(fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::PnpmLike
end
end

it "detects bun from bun.lockb" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("bun.lockb", "")
package_json = described_class.new(fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::BunLike
end
end

it "detects yarn classic from yarn.lock without __metadata:" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "# yarn lockfile v1\n\npackage@^1.0.0:\n version \"1.0.0\"")
package_json = described_class.new(fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "detects yarn berry from yarn.lock with __metadata:" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "__metadata:\n version: 6\n cacheKey: 8")
package_json = described_class.new(fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnBerryLike
end
end

it "defaults to yarn classic if yarn.lock is unreadable" do
with_package_json_file({ "version" => "1.0.0" }) do
File.write("yarn.lock", "")
allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with("#{Dir.pwd}/yarn.lock",
PackageJson::LOCKFILE_DETECTION_READ_SIZE).and_raise(StandardError)
package_json = described_class.new(fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "defaults to yarn classic if yarn.lock disappears before reading (race condition)" do
with_package_json_file({ "version" => "1.0.0" }) do
# Simulate race condition: file exists during initial check but not when reading version
lockfile_path = "#{Dir.pwd}/yarn.lock"
allow(File).to receive(:exist?).and_call_original
allow(File).to receive(:exist?).with(lockfile_path).and_return(true, false)
package_json = described_class.new(fallback_manager: :npm)

expect(package_json.manager).to be_a PackageJson::Managers::YarnClassicLike
end
end

it "does not add the packageManager property" do
with_package_json_file({ "version" => "1.0.0" }) do
described_class.new(fallback_manager: :yarn_classic)
Expand Down