gemvault 0.2.4 → 0.2.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f95c7549060cbc79cadfe917e554bdaf894a1826351ab1935fe90e29197dcf82
4
- data.tar.gz: 46247a46388af9ea35772c647456a80bbd68b7e96f98d0b3c2b456982b320ac5
3
+ metadata.gz: 4447e59a767dbc08bd6c0964964fefaf00bb1f2768421f7ce1fc6247f9e8f2c4
4
+ data.tar.gz: b017dc605efee9f1a576b1fc362b2cf51ebab25e48b424c2be7e50d9cb5f1e3f
5
5
  SHA512:
6
- metadata.gz: eadddf04aad0f8307523984ddf26ee790e1b78a24480eeba13ba35f778521140e833d7ebfc307daf5edbb8ab666290feb05829fa92a787d1b5134568a911faa9
7
- data.tar.gz: cd9dad01cfe52dc4f2d828ae5908a6bd40b2077adcdd45e7059901b7712762fc99fc407ee33d5fea3f80a46c912e7f3c1453c7596306073d1de68e6707908565
6
+ metadata.gz: 5c993d8b4fd00d8c35ed6de9645653400882565860f44449e7adbbcd53730ad7ca161e4cae10e7e817f512c3b63e2101e2ceb58496ebebe97eadd32eceefc46a
7
+ data.tar.gz: 39bfbb4959725d6ad92401dfd3d6841ea72c94021031bdf7680bdde9f2563bd721b51329db7cd76eaf0ef01a32dbfb0a9a788e7795befc50cdc6396f8c2db595
data/CHANGELOG.md CHANGED
@@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
24
24
  is shared with the RubyGems source via `Gemvault::VaultPath` (issue #9).
25
25
 
26
26
  ### Fixed
27
+ - `gemvault doctor` now repairs ghost installations: a
28
+ `specifications/<gem>-<version>.gemspec` left behind after its gem directory
29
+ was removed (interrupted uninstall, hand-cleaned gem home), even when the
30
+ record itself is truncated or unreadable. Such a ghost of
31
+ `bundler-source-vault` makes every `bundle install` fail with
32
+ `Bundler::Plugin::MalformattedPlugin (plugins.rb was not found in the
33
+ plugin.)` — Bundler reinstalls the gem correctly but validates the ghost's
34
+ dead path — and nothing project-local can recover, because the wreck lives
35
+ in the ambient gem home (issue #23).
27
36
  - `bundle plugin install bundler-source-vault` no longer dies with
28
37
  `LoadError: cannot load such file -- bundler/plugin/vault_source`. The shim's
29
38
  `plugins.rb` now derives the gem root from its own installed location (local
data/CLAUDE.md CHANGED
@@ -60,6 +60,7 @@ gem install --source file:///path/to/myvault.gemv foo
60
60
  - `gemvault.gemspec` — main gem spec (name: `gemvault`)
61
61
  - `lib/gemvault/vault.rb` — Vault facade choosing a backend by file format (Tarvault current, legacy Dbvault read-only)
62
62
  - `lib/gemvault/cli.rb` — CLI dispatcher (new/add/list/remove/extract)
63
+ - `lib/gemvault/ghost_specification.rb` — installation records whose gem directory is gone; swept by `gemvault doctor` (issue #23)
63
64
  - `lib/bundler/plugin/vault_source.rb` — Bundler `Plugin::API::Source` implementation
64
65
  - `lib/rubygems_plugin.rb` — RubyGems plugin: monkey-patches for `--source myvault.gemv` support
65
66
  - `lib/rubygems/source/vault.rb` — `Gem::Source::Vault` class (spec loading, download, `file://` URI handling, verbose logging)
@@ -115,9 +116,9 @@ bundle exec rake spec:teardown # remove it
115
116
  Integration specs serve the tree's own gems from a local gem index (`GemIndex`)
116
117
  to avoid rubygems.org resolution during testing.
117
118
 
118
- Rubycritic scores `lib/`, `test/` and `shim/` but not `spec/`: flog taxes each
119
- block-nesting level, so idiomatic describe/context nesting reads as complexity.
120
- rubocop-rspec owns spec style.
119
+ Rubycritic scores `lib/`, `spec/support/`, `test/` and `shim/` but not spec
120
+ example files: flog taxes each block-nesting level, so idiomatic
121
+ describe/context nesting reads as complexity. rubocop-rspec owns spec style.
121
122
 
122
123
  ### Container fidelity — do not undo these
123
124
 
data/README.md CHANGED
@@ -70,9 +70,20 @@ When Bundler sees `type: :vault` in your Gemfile, it auto-installs the `bundler-
70
70
 
71
71
  The RubyGems plugin works similarly: `gem install --source vault.gemv` loads specs and extracts gems on demand.
72
72
 
73
- ## Recovering from a broken bundler plugin path: `gemvault doctor`
73
+ ## Recovering from broken bundler plugin state: `gemvault doctor`
74
74
 
75
- If you installed `bundler-source-vault` from a local path (e.g. `plugin "bundler-source-vault", path: "/path/to/gemvault"` in a Gemfile), bundler records that absolute path in its plugin index. Moving, renaming, or deleting the source directory afterwards invalidates the stored path, and the next `bundle install` prints:
75
+ Two kinds of wreckage make every `bundle install` fail, and `gemvault doctor` repairs both.
76
+
77
+ **A ghost installation.** If a gem home holds a `specifications/bundler-source-vault-<version>.gemspec` whose `gems/` directory is gone (an interrupted `gem uninstall`, a hand-cleaned gem home), bundler trusts the leftover record and every install dies with:
78
+
79
+ ```
80
+ Installing bundler-source-vault 0.2.4
81
+ Failed to install plugin `bundler-source-vault`, due to Bundler::Plugin::MalformattedPlugin (plugins.rb was not found in the plugin.)
82
+ ```
83
+
84
+ Deleting `.bundle` or the lockfile cannot help — the wreck lives in the machine's gem home, not the project. `doctor` finds such records across your gem roots, removes them (reporting each), and reinstalls the plugin.
85
+
86
+ **A broken plugin path.** If you installed `bundler-source-vault` from a local path (e.g. `plugin "bundler-source-vault", path: "/path/to/gemvault"` in a Gemfile), bundler records that absolute path in its plugin index. Moving, renaming, or deleting the source directory afterwards invalidates the stored path, and the next `bundle install` prints:
76
87
 
77
88
  ```
78
89
  The following plugin paths don't exist: /path/to/gemvault/shim/.
@@ -87,7 +98,7 @@ To recover, update the Gemfile to point at the new path and run:
87
98
  gemvault doctor
88
99
  ```
89
100
 
90
- `doctor` clears the broken entry from bundler's plugin index (`bundle plugin uninstall bundler-source-vault`) and then re-runs `bundle install`, which reinstalls the plugin against whatever the current Gemfile declares. Run it from your project directory.
101
+ `doctor` removes any ghost installation records, clears the broken entry from bundler's plugin index (`bundle plugin uninstall bundler-source-vault`) and then re-runs `bundle install`, which reinstalls the plugin against whatever the current Gemfile declares. Run it from your project directory. If a ghost record sits in a root-owned gem home, doctor says so on one line — re-run it with permissions for that gem home (e.g. `sudo gemvault doctor`).
91
102
 
92
103
  The published `bundler-source-vault` gem installed from rubygems.org is immune to this: it lives in a bundler-managed directory that does not move.
93
104
 
data/issues.rec CHANGED
@@ -1017,3 +1017,73 @@ Description: /workspace/.bundle/plugin/gems/bundler-source-vault-0.2.2/gemvault_
1017
1017
  + /workspace/.bundle/plugin/gems/bundler-source-vault-0.2.2/gemvault_load_path.rb:32: warning: already initialized constant BundlerSourceVault::GemvaultLoadPath::SHIM
1018
1018
  + /workspace/.bundle/plugin/gems/bundler-source-vault-0.2.2/gemvault_load_path.rb:33: warning: already initialized constant BundlerSourceVault::GemvaultLoadPath::VAULT_SOURCE
1019
1019
  Status: open
1020
+
1021
+ Id: 23
1022
+ Updated: Mon, 10 Aug 2026 17:40:28 -0400
1023
+ Name: URGENT bundler plugin completely broken
1024
+ Description: ❯
1025
+ + ❯ bundle install
1026
+ + Fetching gem metadata from https://rubygems.org/.......
1027
+ + Resolving dependencies...
1028
+ + Installing bundler-source-vault 0.2.4
1029
+ + Failed to install plugin `bundler-source-vault`, due to Bundler::Plugin::MalformattedPlugin (plugins.rb was not found in the plugin.)
1030
+ +
1031
+ + RESOLUTION: the machine, not the gem. bundler-source-vault-0.2.4 as published
1032
+ + contains plugins.rb (verified against rubygems.org; identical to 0.2.2 but for
1033
+ + versions). The trigger is a ghost installation in an ambient gem root:
1034
+ + specifications/bundler-source-vault-0.2.4.gemspec still present after
1035
+ + gems/bundler-source-vault-0.2.4/ was removed (interrupted uninstall or
1036
+ + hand-cleaned gem home). Bundler's Gemfile-driven plugin install then walks
1037
+ + into an upstream bug: resolution materializes the plugin as the installed
1038
+ + stub; rubygems.org's compact index carries created_at, and the cooldown
1039
+ + backfill (Source::Rubygems#backfill_created_at) attaches the remote to that
1040
+ + stub; download() therefore swaps the freshly fetched package spec into the
1041
+ + stub, and the installed? check inside install() memoizes that spec's
1042
+ + full_gem_path while its loaded_from is still nil -- the Gem.dir fallback,
1043
+ + i.e. the ghost's ambient path. Gem::Installer then extracts the gem
1044
+ + correctly into .bundle/plugin, but the poisoned memo survives, install()
1045
+ + copies it back onto the stub, and validate_plugin! checks plugins.rb against
1046
+ + the dead ambient directory instead of the copy it just installed. Hence the
1047
+ + report's shape -- "Installing bundler-source-vault 0.2.4" every run, then
1048
+ + MalformattedPlugin -- and hence its permanence: rm -rf .bundle, deleting the
1049
+ + lockfile and gemvault doctor all rebuilt project state while the wreck sat
1050
+ + in the ambient gem home. Present in bundler 4.0.17, 4.0.18 and master;
1051
+ + worth filing at rubygems/rubygems.
1052
+ +
1053
+ + gemvault doctor now repairs the wreck: Gemvault::GhostSpecification finds
1054
+ + specifications whose gem directory is gone across Gem.path for the two gems
1055
+ + this project ships, and doctor removes them (reporting each) before its
1056
+ + existing uninstall-and-reinstall; a removal it cannot perform is one line on
1057
+ + stderr and exit 1. Replicated first by spec/integration/ghost_plugin_spec.rb
1058
+ + (real container, real ghost, real exit code): bundle install fails with
1059
+ + exactly the reported message, and the doctor context recovers to a complete
1060
+ + bundle. Unit cover in spec/gemvault/ghost_specification_spec.rb and
1061
+ + spec/gemvault/cli/commands/doctor_spec.rb.
1062
+ +
1063
+ + The integration suite could not have caught this: GemIndex served only the
1064
+ + legacy marshal index, and only a compact index that carries created_at sends
1065
+ + Bundler down the stub-with-remote path. GemIndex now serves the compact
1066
+ + index too (versions and info/NAME with checksum and created_at, rendered by
1067
+ + GemIndexFiles), matching what rubygems.org sends, with the marshal files
1068
+ + kept for `gem install --source`.
1069
+ +
1070
+ + Review hardening: detection evaluates no Ruby. The gem directory's name is
1071
+ + the record's file name minus .gemspec by RubyGems' install convention, so a
1072
+ + missing payload is recognized from the filesystem alone; scoping to the
1073
+ + asked gem reads the record's "# stub:" header when readable and falls back
1074
+ + to the filename. That sweeps truncated or unreadable records -- wreckage in
1075
+ + their own right, and Bundler's stub path needs only the header line, so a
1076
+ + truncated record can keep triggering this bug -- instead of silently
1077
+ + skipping them, and an unreadable neighboring record no longer aborts the
1078
+ + scan. Deliberately out of scope: a gem directory that exists but lost part
1079
+ + of its payload (same MalformattedPlugin symptom via a shorter path).
1080
+ + Telling that wreck apart from a healthy install would mean trusting the
1081
+ + record's file list, and a false positive there deletes a working
1082
+ + installation's record; a report of that wreck reopens this.
1083
+ Status: closed
1084
+
1085
+ Id: 24
1086
+ Updated: Mon, 10 Aug 2026 23:16:35 +0000
1087
+ Name: doctor prints a backtrace when the plugin uninstall step fails
1088
+ Description: gemvault doctor's `bundle plugin uninstall` step runs with exception: true; a nonzero exit raises RuntimeError and a missing bundle raises Errno::ENOENT, and both escape Command#run to command_kit's ExceptionHandler, which prints error.full_message -- a full backtrace -- violating the one-line-and-exit-1 convention issue #15 established. Adjacent to issue #14 (doctor failing under bundler/inline). The ghost-specification sweep added for issue #23 reports its own failures as one line; the uninstall step should match.
1089
+ Status: open
@@ -1,27 +1,56 @@
1
1
  require_relative "../command"
2
+ require_relative "../../ghost_specification"
2
3
 
3
4
  module Gemvault
4
5
  class CLI
5
6
  module Commands
6
- # Recovers from a bundler plugin index entry whose stored path no
7
- # longer exists. Bundler records absolute paths in .bundle/plugin/index
8
- # for path-installed plugins. Moving or renaming the source directory
7
+ # Recovers from wrecked bundler plugin state. Two wrecks are repaired:
8
+ #
9
+ # A bundler plugin index entry whose stored path no longer exists.
10
+ # Bundler records absolute paths in .bundle/plugin/index for
11
+ # path-installed plugins. Moving or renaming the source directory
9
12
  # leaves an invalid path behind -- Bundler::Plugin.load_plugin warns
10
13
  # "The following plugin paths don't exist: ..." and silently returns,
11
14
  # leaving @sources[<type>] nil. The next `source X, type: :vault`
12
15
  # crashes inside Bundler::SourceList#add_plugin_source with
13
- # NoMethodError on nil.
16
+ # NoMethodError on nil. Uninstalling clears the broken entry.
17
+ #
18
+ # A ghost specification: an installation record whose gem directory is
19
+ # gone (see Gemvault::GhostSpecification). Bundler's plugin installer
20
+ # trusts the record and validates plugins.rb against the missing
21
+ # directory instead of the copy it just installed, so every
22
+ # `bundle install` fails with MalformattedPlugin (issue #23). Removing
23
+ # the record lets the reinstall see the machine as it is.
14
24
  #
15
- # Uninstalling clears the broken entry; re-running bundle install
16
- # triggers Bundler to reinstall the plugin against whatever the
17
- # current Gemfile declares. Run this from the project directory.
25
+ # Re-running bundle install afterwards triggers Bundler to reinstall
26
+ # the plugin against whatever the current Gemfile declares. Run this
27
+ # from the project directory.
18
28
  class Doctor < Command
19
- description "Clear a broken bundler-source-vault plugin index entry and reinstall it"
29
+ description "Repair broken bundler-source-vault plugin state and reinstall the plugin"
30
+
31
+ OWNED_GEMS = %w[gemvault bundler-source-vault].freeze
32
+
33
+ PERMISSION_HINT = "(re-run with permissions for that gem home, e.g. sudo gemvault doctor)".freeze
20
34
 
21
35
  def run
36
+ begin
37
+ remove_ghost_specifications
38
+ rescue SystemCallError => e
39
+ print_error("#{e.message} #{PERMISSION_HINT}")
40
+ exit(1)
41
+ end
22
42
  system("bundle", "plugin", "uninstall", "bundler-source-vault", exception: true)
23
43
  exec("bundle", "install")
24
44
  end
45
+
46
+ private
47
+
48
+ def remove_ghost_specifications
49
+ OWNED_GEMS.flat_map { |name| GhostSpecification.of(name) }.each do |ghost|
50
+ ghost.delete
51
+ puts "Removed ghost specification #{ghost}"
52
+ end
53
+ end
25
54
  end
26
55
  end
27
56
  end
@@ -0,0 +1,87 @@
1
+ require "pathname"
2
+
3
+ module Gemvault
4
+ ##
5
+ # A gem installation record -- <tt>specifications/<full name>.gemspec</tt> --
6
+ # whose <tt>gems/<full name></tt> directory is gone: the wreckage of an
7
+ # interrupted uninstall or a hand-cleaned gem home.
8
+ #
9
+ # RubyGems still lists such a gem as installed, and Bundler's Gemfile-driven
10
+ # plugin installer trusts that record: it materializes the plugin against the
11
+ # ambient gem roots, reinstalls the missing gem into the plugin root, but
12
+ # then validates <tt>plugins.rb</tt> against the ghost's dead directory
13
+ # instead of the copy it just extracted. Every <tt>bundle install</tt> after
14
+ # that dies with <tt>MalformattedPlugin (plugins.rb was not found in the
15
+ # plugin.)</tt> -- issue #23. Removing the ghost record lets the next install
16
+ # see the machine as it really is.
17
+ #
18
+ # Detection never evaluates the record. The gem directory's name is the
19
+ # record's file name minus <tt>.gemspec</tt> by RubyGems' own install
20
+ # convention, so a missing payload is recognized from the filesystem alone --
21
+ # which lets a truncated or unreadable record, wreckage in its own right, be
22
+ # swept rather than silently skipped. Scoping to the asked gem reads the
23
+ # record's "# stub:" header when it is readable and falls back to the
24
+ # filename when it is not.
25
+ #
26
+ # Deliberately out of scope: a gem directory that exists but has lost part of
27
+ # its payload. Telling that wreck apart from a healthy install would mean
28
+ # trusting the record's file list, and a false positive here deletes a
29
+ # working installation's record.
30
+ class GhostSpecification
31
+ # All ghost specifications of +name+ across +roots+ (gem homes as listed
32
+ # on Gem.path, the same view Bundler's plugin installer resolves against).
33
+ def self.of(name, roots: Gem.path)
34
+ roots.flat_map { |root| in_root(Pathname(root), name) }
35
+ end
36
+
37
+ def self.in_root(root, name)
38
+ root.join("specifications").glob("#{name}-*.gemspec")
39
+ .select { |file| of_gem?(file, name) }
40
+ .reject { |file| root.join("gems", file.basename(".gemspec").to_s).directory? }
41
+ .map { |file| new(file) }
42
+ end
43
+ private_class_method :in_root
44
+
45
+ def self.of_gem?(file, name)
46
+ stub_name = stub_header_name(file)
47
+ return stub_name == name unless stub_name.nil?
48
+
49
+ version_shaped?(file.basename(".gemspec").to_s.delete_prefix("#{name}-"))
50
+ end
51
+ private_class_method :of_gem?
52
+
53
+ # Installed records open with an optional encoding magic comment followed
54
+ # by "# stub: <name> <version> <platform> <require paths>".
55
+ def self.stub_header_name(file)
56
+ file.open("r") do |io|
57
+ 2.times do
58
+ line = io.gets or break
59
+ name = line[/\A# stub: (\S+) /, 1] and return name
60
+ end
61
+ end
62
+ nil
63
+ rescue SystemCallError
64
+ nil
65
+ end
66
+ private_class_method :stub_header_name
67
+
68
+ # A version starts with a digit and a gem-name segment rarely does; with
69
+ # no stub header left to say so, that is the best remaining evidence, and
70
+ # it is only ever weighed for a record whose payload is already missing.
71
+ def self.version_shaped?(remainder) = remainder.match?(/\A\d/)
72
+ private_class_method :version_shaped?
73
+
74
+ attr_reader :file
75
+
76
+ def initialize(file)
77
+ @file = file
78
+ end
79
+ private_class_method :new
80
+
81
+ def to_s = file.to_s
82
+
83
+ def delete
84
+ File.delete(file)
85
+ end
86
+ end
87
+ end
@@ -1,3 +1,3 @@
1
1
  module Gemvault
2
- VERSION = "0.2.4".freeze
2
+ VERSION = "0.2.5".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -74,6 +74,7 @@ files:
74
74
  - lib/gemvault/gem_reference/any_version.rb
75
75
  - lib/gemvault/gem_reference/parser.rb
76
76
  - lib/gemvault/gem_reference/specific_version.rb
77
+ - lib/gemvault/ghost_specification.rb
77
78
  - lib/gemvault/manifest.rb
78
79
  - lib/gemvault/tarball.rb
79
80
  - lib/gemvault/tarvault.rb