diff --git a/.rubocop.yml b/.rubocop.yml index 99da2d4..6f153bc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -199,6 +199,7 @@ Metrics/AbcSize: Metrics/ClassLength: Exclude: - 'spec/**/*' + - 'lib/package_json.rb' Naming/MemoizedInstanceVariableName: EnforcedStyleForLeadingUnderscores: optional diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a2e05..a379782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 16f0442..afeb4b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/README.md b/README.md index 4582901..e7ffef9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/lib/package_json.rb b/lib/package_json.rb index f58d31c..5c0042a 100644 --- a/lib/package_json.rb +++ b/lib/package_json.rb @@ -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 @@ -75,8 +79,18 @@ 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" @@ -84,6 +98,37 @@ def determine_package_manager(fallback_manager) 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? diff --git a/sig/package_json.rbs b/sig/package_json.rbs index f7a5fa6..6ee7dbc 100644 --- a/sig/package_json.rbs +++ b/sig/package_json.rbs @@ -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 diff --git a/spec/package_json_spec.rb b/spec/package_json_spec.rb index 67dc31c..764efc1 100644 --- a/spec/package_json_spec.rb +++ b/spec/package_json_spec.rb @@ -136,7 +136,7 @@ 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) @@ -144,6 +144,107 @@ 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) @@ -306,7 +407,7 @@ 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) @@ -314,6 +415,75 @@ 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)