gemvault 0.2.4 → 0.2.6

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: 6c8bf335258cabfbfbe016fe547e1302da87c37060d1523cae394e941bd493dd
4
+ data.tar.gz: f4a027c561cb8edf619f777989654d657bae70d741106ba8730b540b38de7f2f
5
5
  SHA512:
6
- metadata.gz: eadddf04aad0f8307523984ddf26ee790e1b78a24480eeba13ba35f778521140e833d7ebfc307daf5edbb8ab666290feb05829fa92a787d1b5134568a911faa9
7
- data.tar.gz: cd9dad01cfe52dc4f2d828ae5908a6bd40b2077adcdd45e7059901b7712762fc99fc407ee33d5fea3f80a46c912e7f3c1453c7596306073d1de68e6707908565
6
+ metadata.gz: 9f3691b960faaa2d6ab493d18363091691ce3ac106a23d8859a052e946fd6aaa78178282da8269823e07c21cf52cb5ba5e9e262c51767aea19d805b620ee6287
7
+ data.tar.gz: 99feeb31a5af138c4da80b61b3fe3853cd545044954b895e202d57a5f7c2817b3cb59701f752140efbce75c839120f4b6bcf0a5871a806a94cc80cc6d12670ed
data/CHANGELOG.md CHANGED
@@ -24,6 +24,38 @@ 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).
36
+ - `gemvault doctor` now works in a project whose Gemfile is inline
37
+ (`bundler/inline`). Such a script keeps its plugin index in
38
+ `<project>/.bundle/plugin`, a root bundler only consults mid-script, so
39
+ doctor's uninstall silently repaired nothing and the `bundle install` that
40
+ followed dumped bundler's entire usage screen. Doctor now reaches the
41
+ project's index the way `bundler/inline` does and, with no Gemfile to
42
+ reinstall from, says so and exits 0 — the inline gemfile reinstalls the
43
+ plugin the next time the script runs (issue #14). Run outside any project,
44
+ doctor no longer claims to have cleared a project index it never touched.
45
+ - `gemvault doctor` no longer strands a machine without the plugin. Its
46
+ closing `bundle install` does more than reinstall the plugin, and when that
47
+ extra work failed (a Ruby version pin, an unresolvable gem) the already-run
48
+ uninstall had cleared the plugin index for nothing — strictly worse than
49
+ the wreck doctor was asked to repair. The index is now snapshotted before
50
+ the uninstall and restored when the reinstall never happened, `bundle
51
+ install` runs as a child rather than replacing the process, and every
52
+ failure is one line on stderr and exit 1 instead of a backtrace
53
+ (issues #27, #24).
54
+ - The shim no longer spams `already initialized constant` warnings when
55
+ bundler evaluates two installed copies of it in one process — an upgrade
56
+ resolving a newer bundler-source-vault while the plugin index still names
57
+ the old one. The first copy wins, matching the guard plugins.rb already
58
+ applies to the vault source class (issue #26).
27
59
  - `bundle plugin install bundler-source-vault` no longer dies with
28
60
  `LoadError: cannot load such file -- bundler/plugin/vault_source`. The shim's
29
61
  `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,11 @@ 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, when a Gemfile exists, 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`).
102
+
103
+ A project whose Gemfile is inline (`require "bundler/inline"`) works too. Such a script keeps its plugin index in `<project>/.bundle/plugin`, a root bundler only consults while the script runs; doctor reaches it the same way the script did. With no Gemfile on disk there is nothing to reinstall from, so doctor clears the entry, says so, and exits 0 — the inline gemfile reinstalls the plugin the next time the script runs. Run outside any project, doctor works against bundler's global index and points you back at the project directory for the reinstall.
104
+
105
+ The repair is transactional. `bundle install` does more than reinstall the plugin, and that extra work can fail on its own — a Ruby version pin, an unresolvable gem. When it does, doctor says so in one line and exits 1; and if the failure struck before the plugin was reinstalled, doctor puts the plugin index back the way it found it. A failed run never leaves the machine with less than it started with — fix the install error and re-run.
91
106
 
92
107
  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
108
 
data/issues.rec CHANGED
@@ -750,7 +750,42 @@ Id: 14
750
750
  Updated: Wed, 29 Jul 2026 16:41:11 -0400
751
751
  Name: gemvault doctor fails when bundler/inline is used
752
752
  Description: Loudly fails with 2 errors, one of which is "can't find gemfile"
753
- Status: open
753
+ +
754
+ + RESOLUTION: both errors reproduced in podman, and doctor turned out to be
755
+ + broken twice over rather than once.
756
+ +
757
+ + (1) "Plugin bundler-source-vault is not installed". bundler/inline sets
758
+ + BUNDLE_GEMFILE to a bare "Gemfile" for the life of the script (bundler
759
+ + inline.rb:53), so Bundler.root is the script's working directory and the plugin
760
+ + index is written to <project>/.bundle/plugin. A later `gemvault doctor` sets no
761
+ + such variable and finds no Gemfile on disk, so Bundler::Plugin.root --
762
+ + local_root only when SharedHelpers.in_bundle?, global_root otherwise
763
+ + (plugin.rb:162) -- falls back to ~/.bundle/plugin. The uninstall therefore
764
+ + looked in the wrong root and repaired nothing, silently.
765
+ +
766
+ + (2) "Could not locate Gemfile". doctor then exec'd `bundle install`, which with
767
+ + no Gemfile prints its entire usage screen and exits 10 -- reading as gemvault
768
+ + itself failing, after the repair it was asked for had already not happened.
769
+ +
770
+ + doctor now runs the uninstall with BUNDLE_GEMFILE="Gemfile" when the project
771
+ + owns a plugin root Bundler would ignore, which is bundler/inline's own trick and
772
+ + needs no file to exist; verified against the real thing (plain uninstall says
773
+ + "not installed", the same command with BUNDLE_GEMFILE set says "Uninstalled
774
+ + plugin bundler-source-vault" and empties the index). When no Gemfile exists it
775
+ + skips the reinstall and says so, exiting 0, because clearing the entry is the
776
+ + repair and only the reinstall is unavailable -- an inline gemfile reinstalls the
777
+ + plugin the next time the script runs.
778
+ +
779
+ + Gemvault::BundlerGemfile mirrors SharedHelpers#find_gemfile and
780
+ + Gemvault::BundlerPluginRoot mirrors Plugin.root, both reimplemented rather than
781
+ + delegated because bundler is deliberately not a dependency and doctor runs
782
+ + outside any Bundler process. Replicated first by
783
+ + spec/integration/cli/commands/doctor_inline_spec.rb -- all 8 examples failed
784
+ + against the old code -- with unit cover in spec/gemvault/bundler_gemfile_spec.rb,
785
+ + spec/gemvault/bundler_plugin_root_spec.rb and the extended doctor_spec.rb. The
786
+ + inline support now runs its script from the project directory, as a real user
787
+ + does; that is what puts the plugin root where the report describes.
788
+ Status: closed
754
789
 
755
790
  Id: 15
756
791
  Updated: Wed, 29 Jul 2026 17:39:23 -0400
@@ -783,7 +818,11 @@ Id: 16
783
818
  Updated: Wed, 29 Jul 2026 18:25:48 -0400
784
819
  Name: Add jruby-ci workflow
785
820
  Description: It should be already set up on the jruby-support branch. It just needs to be picked from it. DO NOT use the other code in that - it is very outdated
786
- Status: open
821
+ +
822
+ + RESOLUTION: shipped in commit 635ba41 ("ci: add JRuby test job") -- the jruby
823
+ + job in .github/workflows/ci.yml runs the minitest suite on jruby-10.1.0.0 and
824
+ + is green on CI.
825
+ Status: closed
787
826
 
788
827
  Id: 17
789
828
  Updated: Thu, 30 Jul 2026 00:07:42 +0000
@@ -972,7 +1011,15 @@ Description: [dev@rstore]/workspace% bundle install
972
1011
  + from /usr/local/bin/bundle:25:in '<main>'
973
1012
  +
974
1013
  + Though I will add that gemvault doctor followed by bundle install fixed the issue
975
- Status: open
1014
+ +
1015
+ + RESOLUTION: the broken-plugin-path wreck, recovered exactly as reported --
1016
+ + gemvault doctor then bundle install. The plugin index entry pointed into a
1017
+ + reinstalled rbenv ruby's gem home; bundler does not revalidate stored plugin
1018
+ + paths, skips loading the plugin, and crashes in SourceList#add_plugin_source,
1019
+ + upstream behavior README documents under "A broken plugin path". doctor is
1020
+ + the repair. Hinting at doctor from the failure site was considered and
1021
+ + declined: the crash happens in bundler frames before any gemvault code loads.
1022
+ Status: closed
976
1023
 
977
1024
  Id: 21
978
1025
  Updated: Fri, 31 Jul 2026 13:54:09 -0400
@@ -1011,9 +1058,168 @@ Description: bundler-cache: true on the ruby/setup-ruby action will not work cor
1011
1058
  + run: | # confine gems to vendor/bundle so the cache never shadows vendor/vendored.gemv
1012
1059
  + bundle config set --local path vendor/bundle
1013
1060
  + bundle install --jobs 4 --retry 3
1061
+ Status: open
1062
+
1063
+ Id: 26
1014
1064
  Updated: Mon, 10 Aug 2026 10:14:51 -0400
1015
1065
  Name: Gemvault spams warnings on bundle install
1016
1066
  Description: /workspace/.bundle/plugin/gems/bundler-source-vault-0.2.2/gemvault_load_path.rb:31: warning: already initialized constant BundlerSourceVault::GemvaultLoadPath::GEM
1017
1067
  + /workspace/.bundle/plugin/gems/bundler-source-vault-0.2.2/gemvault_load_path.rb:32: warning: already initialized constant BundlerSourceVault::GemvaultLoadPath::SHIM
1018
1068
  + /workspace/.bundle/plugin/gems/bundler-source-vault-0.2.2/gemvault_load_path.rb:33: warning: already initialized constant BundlerSourceVault::GemvaultLoadPath::VAULT_SOURCE
1069
+ +
1070
+ + RESOLUTION: bundler can evaluate two installed copies of the shim in one
1071
+ + process -- an upgrade resolving a newer bundler-source-vault while the index
1072
+ + still names the old one, the dual-version plugin root the failure report
1073
+ + also recorded as issue #29 -- and require_relative cannot deduplicate
1074
+ + across paths, so the second copy reopened GemvaultLoadPath and warned on
1075
+ + every constant. gemvault_load_path.rb now returns before redefining
1076
+ + anything when the module is already loaded, first copy winning as
1077
+ + plugins.rb already arranges for Bundler::Plugin::VaultSource. Replicated by
1078
+ + spec/shim_reload_spec.rb (two copies loaded in one process, red against the
1079
+ + old shim), with the plain-install path guarded in
1080
+ + spec/integration/bundle_install_spec.rb.
1081
+ Status: closed
1082
+
1083
+ Id: 23
1084
+ Updated: Mon, 10 Aug 2026 17:40:28 -0400
1085
+ Name: URGENT bundler plugin completely broken
1086
+ Description: ❯
1087
+ + ❯ bundle install
1088
+ + Fetching gem metadata from https://rubygems.org/.......
1089
+ + Resolving dependencies...
1090
+ + Installing bundler-source-vault 0.2.4
1091
+ + Failed to install plugin `bundler-source-vault`, due to Bundler::Plugin::MalformattedPlugin (plugins.rb was not found in the plugin.)
1092
+ +
1093
+ + RESOLUTION: the machine, not the gem. bundler-source-vault-0.2.4 as published
1094
+ + contains plugins.rb (verified against rubygems.org; identical to 0.2.2 but for
1095
+ + versions). The trigger is a ghost installation in an ambient gem root:
1096
+ + specifications/bundler-source-vault-0.2.4.gemspec still present after
1097
+ + gems/bundler-source-vault-0.2.4/ was removed (interrupted uninstall or
1098
+ + hand-cleaned gem home). Bundler's Gemfile-driven plugin install then walks
1099
+ + into an upstream bug: resolution materializes the plugin as the installed
1100
+ + stub; rubygems.org's compact index carries created_at, and the cooldown
1101
+ + backfill (Source::Rubygems#backfill_created_at) attaches the remote to that
1102
+ + stub; download() therefore swaps the freshly fetched package spec into the
1103
+ + stub, and the installed? check inside install() memoizes that spec's
1104
+ + full_gem_path while its loaded_from is still nil -- the Gem.dir fallback,
1105
+ + i.e. the ghost's ambient path. Gem::Installer then extracts the gem
1106
+ + correctly into .bundle/plugin, but the poisoned memo survives, install()
1107
+ + copies it back onto the stub, and validate_plugin! checks plugins.rb against
1108
+ + the dead ambient directory instead of the copy it just installed. Hence the
1109
+ + report's shape -- "Installing bundler-source-vault 0.2.4" every run, then
1110
+ + MalformattedPlugin -- and hence its permanence: rm -rf .bundle, deleting the
1111
+ + lockfile and gemvault doctor all rebuilt project state while the wreck sat
1112
+ + in the ambient gem home. Present in bundler 4.0.17, 4.0.18 and master;
1113
+ + worth filing at rubygems/rubygems.
1114
+ +
1115
+ + gemvault doctor now repairs the wreck: Gemvault::GhostSpecification finds
1116
+ + specifications whose gem directory is gone across Gem.path for the two gems
1117
+ + this project ships, and doctor removes them (reporting each) before its
1118
+ + existing uninstall-and-reinstall; a removal it cannot perform is one line on
1119
+ + stderr and exit 1. Replicated first by spec/integration/ghost_plugin_spec.rb
1120
+ + (real container, real ghost, real exit code): bundle install fails with
1121
+ + exactly the reported message, and the doctor context recovers to a complete
1122
+ + bundle. Unit cover in spec/gemvault/ghost_specification_spec.rb and
1123
+ + spec/gemvault/cli/commands/doctor_spec.rb.
1124
+ +
1125
+ + The integration suite could not have caught this: GemIndex served only the
1126
+ + legacy marshal index, and only a compact index that carries created_at sends
1127
+ + Bundler down the stub-with-remote path. GemIndex now serves the compact
1128
+ + index too (versions and info/NAME with checksum and created_at, rendered by
1129
+ + GemIndexFiles), matching what rubygems.org sends, with the marshal files
1130
+ + kept for `gem install --source`.
1131
+ +
1132
+ + Review hardening: detection evaluates no Ruby. The gem directory's name is
1133
+ + the record's file name minus .gemspec by RubyGems' install convention, so a
1134
+ + missing payload is recognized from the filesystem alone; scoping to the
1135
+ + asked gem reads the record's "# stub:" header when readable and falls back
1136
+ + to the filename. That sweeps truncated or unreadable records -- wreckage in
1137
+ + their own right, and Bundler's stub path needs only the header line, so a
1138
+ + truncated record can keep triggering this bug -- instead of silently
1139
+ + skipping them, and an unreadable neighboring record no longer aborts the
1140
+ + scan. Deliberately out of scope: a gem directory that exists but lost part
1141
+ + of its payload (same MalformattedPlugin symptom via a shorter path).
1142
+ + Telling that wreck apart from a healthy install would mean trusting the
1143
+ + record's file list, and a false positive there deletes a working
1144
+ + installation's record; a report of that wreck reopens this.
1145
+ Status: closed
1146
+
1147
+ Id: 24
1148
+ Updated: Mon, 10 Aug 2026 23:16:35 +0000
1149
+ Name: doctor prints a backtrace when the plugin uninstall step fails
1150
+ 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.
1151
+ +
1152
+ + RESOLUTION: fixed alongside issue #27's transactional rework. The uninstall
1153
+ + step no longer runs with exception: true; a failing or missing bundle is one
1154
+ + line on stderr ("doctor: bundle plugin uninstall bundler-source-vault
1155
+ + failed") and exit 1, matching the convention issues #15 and #23 established,
1156
+ + and the snapshotted index is left untouched.
1157
+ Status: closed
1158
+
1159
+ Id: 25
1160
+ Updated: Tue, 11 Aug 2026 20:05:27 -0400
1161
+ Name: Project with Gemfile + locked json version + gemvault throws error
1162
+ Description: Project with Gemfile + locked json version + gemvault throws "bundler: failed to load command: rails (/private/tmp/local/flipmine/flipmine/vendor/ruby/4.0.0/bin/rails)
1163
+ + /Users/davidgillis/.rbenv/versions/4.0.1/lib/ruby/gems/4.0.0/gems/bundler-4.0.18/lib/bundler/runtime.rb:328:in 'Bundler::Runtime#check_for_activated_spec!': You have already activated json 2.21.2, but your Gemfile requires json 2.19.7. Prepending `bundle exec` to your command may solve this. (Gem::LoadError)"
1164
+ +
1165
+ + I believe this error to be coming from gemvault's dependency on JSON. It is clear that dependencies are cursed in this project, whether its the added load time for reinstalling them on every `bundle install`, or errors like this one. Rather than rely on JSON, I think it would be better to just marshal our own manifest, or use some other serialization tool if any that rubygems already provides.
1166
+ Status: open
1167
+
1168
+ Id: 27
1169
+ Updated: Wed, 12 Aug 2026 16:05:55 +0000
1170
+ Name: gemvault doctor can leave the machine with no plugin at all
1171
+ Description: Reported from a container where bundle install could not succeed
1172
+ + (ruby version pin mismatch): doctor removed ghost records, uninstalled the
1173
+ + plugin, emptied .bundle/plugin/index, then exec'd bundle install -- which
1174
+ + failed, leaving no plugin installed. Strictly worse than the broken-path
1175
+ + state doctor was invoked to repair, and recoverable only by knowing to run
1176
+ + `bundle plugin install bundler-source-vault` by hand. doctor repairs by
1177
+ + mutation with no dry-run, no report of findings before acting, and no
1178
+ + rollback when the reinstall it hands off to fails. Suggested in the report:
1179
+ + report-then-fix (--dry-run, or report by default and repair under --fix);
1180
+ + reinstall the plugin directly with `bundle plugin install` and verify the
1181
+ + index rather than exec'ing into `bundle install`, so a failed dependency
1182
+ + install cannot take the plugin down with it; or restore the previous index
1183
+ + when the step after the uninstall fails.
1184
+ +
1185
+ + RESOLUTION: the uninstall-reinstall pair is now transactional. doctor
1186
+ + snapshots the plugin index bundler will consult (Gemvault::BundlerPluginIndex
1187
+ + over BundlerPluginRoot#consulted) before clearing it, runs `bundle install`
1188
+ + as a child instead of exec'ing into it, and on failure checks whether the
1189
+ + plugin came back: reinstalled means the repair stands and the one-line error
1190
+ + says only the bundle is unfinished; not reinstalled means the snapshot is
1191
+ + restored, so the machine is left as found and re-running doctor once the
1192
+ + install error is fixed recovers fully. Replicated first by
1193
+ + spec/integration/cli/commands/doctor_reinstall_failure_spec.rb -- doctor run
1194
+ + against a Gemfile still naming a renamed plugin path, an un-installable
1195
+ + bundle in the reporter's shape -- which fails against the old code and
1196
+ + proves the restored index heals on the next run. Unit cover in
1197
+ + spec/gemvault/bundler_plugin_index_spec.rb and the reworked doctor_spec.rb.
1198
+ Status: closed
1199
+
1200
+ Id: 28
1201
+ Updated: Wed, 12 Aug 2026 16:05:55 +0000
1202
+ Name: plugins.rb dies with a bare LoadError when gemvault is installed nowhere
1203
+ Description: A plugin root holding only the shim -- the normal state wherever
1204
+ + `gem install gemvault` has run, since Bundler skips installing a plugin
1205
+ + dependency already present -- stops loading when the tree lands on a machine
1206
+ + with no gemvault in any gem root: GemvaultLoadPath.entries returns [] and
1207
+ + `require "bundler/plugin/vault_source"` raises a bare LoadError with no hint.
1208
+ + Observed after a macOS working tree was copied into a Linux container (the
1209
+ + shim alone in .bundle/plugin, gemvault absent from every gem root on the
1210
+ + box); also reachable by `gem uninstall gemvault` on a single machine. The
1211
+ + ambient-root search cannot cover gemvault being truly absent. Suggested:
1212
+ + fail with an explicit "gemvault not found in any gem root" message naming
1213
+ + the remedy (gem install gemvault, or bundle plugin uninstall + install to
1214
+ + get a self-contained root), or vendor gemvault into the shim.
1215
+ Status: open
1216
+
1217
+ Id: 29
1218
+ Updated: Wed, 12 Aug 2026 16:05:55 +0000
1219
+ Name: Stale plugin versions are never reaped from the plugin root
1220
+ Description: bundler-source-vault-0.2.4 survived in the plugin root's gems/,
1221
+ + specifications/ and cache/ across the 0.2.5 upgrade and an explicit `bundle
1222
+ + plugin uninstall`; only 0.2.5 was ever referenced by the index. Possibly
1223
+ + Bundler's doing rather than the plugin's, but doctor is well placed to sweep
1224
+ + plugin-root versions the index no longer references.
1019
1225
  Status: open
@@ -0,0 +1,63 @@
1
+ require "pathname"
2
+
3
+ module Gemvault
4
+ ##
5
+ # The Gemfile Bundler would load from a directory.
6
+ #
7
+ # Mirrors Bundler::SharedHelpers#find_gemfile: <tt>BUNDLE_GEMFILE</tt> wins
8
+ # when set and non-empty, otherwise the search walks up looking for
9
+ # <tt>gems.rb</tt> then <tt>Gemfile</tt> in each directory. Reimplemented
10
+ # rather than delegated because bundler is deliberately not a gemvault
11
+ # dependency (see gemvault.gemspec) and +gemvault doctor+ runs as a plain
12
+ # CLI, outside any Bundler process.
13
+ #
14
+ # One difference is deliberate. Bundler returns <tt>BUNDLE_GEMFILE</tt>
15
+ # whatever it points at, existing or not -- bundler/inline relies on that,
16
+ # setting it to a bare "Gemfile" purely to make Bundler treat the working
17
+ # directory as the app root. The question here is whether +bundle install+
18
+ # could work, so a value that does not name a file is ignored and the
19
+ # walk-up search decides instead.
20
+ class BundlerGemfile
21
+ # Checked in this order within each directory, as Bundler checks them.
22
+ NAMES = ["gems.rb", "Gemfile"].freeze
23
+
24
+ def initialize(dir: Dir.pwd, env: ENV)
25
+ @dir = Pathname(dir)
26
+ @env = env
27
+ end
28
+
29
+ # :call-seq:
30
+ # path -> Pathname or nil
31
+ #
32
+ # The Gemfile Bundler would load, or +nil+ when there is none.
33
+ def path
34
+ return @path if defined?(@path)
35
+
36
+ @path = from_env || search_up
37
+ end
38
+
39
+ # Whether Bundler would find a Gemfile at all.
40
+ def exist?
41
+ !path.nil?
42
+ end
43
+
44
+ private
45
+
46
+ def from_env
47
+ given = @env["BUNDLE_GEMFILE"].to_s
48
+ return nil if given.empty?
49
+
50
+ candidate = Pathname(given).expand_path(@dir)
51
+ candidate.file? ? candidate : nil
52
+ end
53
+
54
+ def search_up
55
+ @dir.expand_path.ascend do |dir|
56
+ found = NAMES.map { |name| dir / name }.find(&:file?)
57
+ return found if found
58
+ end
59
+
60
+ nil
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,58 @@
1
+ require "pathname"
2
+
3
+ module Gemvault
4
+ ##
5
+ # Bundler's plugin index file inside a plugin root.
6
+ #
7
+ # The uninstall step of +gemvault doctor+ clears entries from this file, and
8
+ # the reinstall that follows can fail for reasons unrelated to the plugin.
9
+ # Snapshot and restore make that pair transactional: a repair that cannot
10
+ # finish puts the index back instead of leaving the machine with no plugin
11
+ # registered at all (issue #27).
12
+ #
13
+ # The plugin_paths section is scanned textually rather than parsed as YAML:
14
+ # loading a YAML library into doctor's process invites the same
15
+ # stdlib-activation conflicts as issue #25, and bundler's own emitter writes
16
+ # one two-space-indented "name: path" line per plugin.
17
+ class BundlerPluginIndex
18
+ def initialize(root)
19
+ @root = Pathname(root)
20
+ end
21
+
22
+ def file
23
+ @root / "index"
24
+ end
25
+
26
+ # Whether plugin_paths currently lists +plugin+.
27
+ def registered?(plugin)
28
+ plugin_paths.any? { |line| line.match?(/\A\s{2}#{Regexp.escape(plugin)}:/) }
29
+ end
30
+
31
+ # :call-seq:
32
+ # snapshot -> String or nil
33
+ #
34
+ # The index content as it stands, +nil+ when no index exists.
35
+ def snapshot
36
+ file.file? ? file.read : nil
37
+ end
38
+
39
+ # Puts the index back the way +snapshot+ recorded it; restoring +nil+
40
+ # removes an index that did not exist at snapshot time.
41
+ def restore(snapshot)
42
+ return file.write(snapshot) if snapshot
43
+
44
+ file.delete if file.exist?
45
+ end
46
+
47
+ private
48
+
49
+ def plugin_paths
50
+ return [] unless file.file?
51
+
52
+ file.readlines
53
+ .drop_while { |line| !line.start_with?("plugin_paths:") }
54
+ .drop(1)
55
+ .take_while { |line| line.start_with?(" ") }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,59 @@
1
+ require "pathname"
2
+ require_relative "bundler_gemfile"
3
+
4
+ module Gemvault
5
+ ##
6
+ # Where Bundler keeps plugin data for a directory.
7
+ #
8
+ # Bundler::Plugin.root answers the project-local <tt>.bundle/plugin</tt> when
9
+ # <tt>SharedHelpers.in_bundle?</tt> holds, and the global
10
+ # <tt>~/.bundle/plugin</tt> when it does not. Since +in_bundle?+ is just
11
+ # "was a Gemfile found", a project can own a plugin root that Bundler
12
+ # declines to look in.
13
+ class BundlerPluginRoot
14
+ LOCAL_DIR = ".bundle/plugin".freeze
15
+
16
+ def initialize(dir: Dir.pwd, gemfile: BundlerGemfile.new(dir: dir), env: ENV)
17
+ @dir = Pathname(dir)
18
+ @gemfile = gemfile
19
+ @env = env
20
+ end
21
+
22
+ # The project's own plugin root, whether or not Bundler would consult it.
23
+ def local
24
+ @dir.expand_path / LOCAL_DIR
25
+ end
26
+
27
+ # Where Bundler keeps plugins for a user outside any project, mirroring
28
+ # Bundler's user_bundle_path lookup for plugins: <tt>BUNDLE_USER_PLUGIN</tt>
29
+ # names the root directly, <tt>BUNDLE_USER_HOME</tt> relocates
30
+ # <tt>.bundle</tt>, and the home directory is the default.
31
+ def global
32
+ named = @env["BUNDLE_USER_PLUGIN"]
33
+ return Pathname(named) if named
34
+
35
+ Pathname(@env["BUNDLE_USER_HOME"] || File.join(Dir.home, ".bundle")) / "plugin"
36
+ end
37
+
38
+ # The plugin root Bundler will consult here: beside the Gemfile when one
39
+ # was found, the project's own root when doctor points Bundler at it (see
40
+ # #unreachable?), the global root otherwise.
41
+ def consulted
42
+ return @gemfile.path.dirname / LOCAL_DIR if @gemfile.exist?
43
+ return local if unreachable?
44
+
45
+ global
46
+ end
47
+
48
+ # Whether this project has a plugin root that Bundler currently ignores.
49
+ #
50
+ # bundler/inline installs plugins into <tt><cwd>/.bundle/plugin</tt>, having
51
+ # set <tt>BUNDLE_GEMFILE</tt> to a bare "Gemfile" for the duration of the
52
+ # script. A later command in that same directory sets no such variable and
53
+ # finds no Gemfile on disk, so Bundler falls back to the global root and
54
+ # reports the plugin as not installed -- while the entry sits here.
55
+ def unreachable?
56
+ !@gemfile.exist? && local.directory?
57
+ end
58
+ end
59
+ end
@@ -1,26 +1,143 @@
1
1
  require_relative "../command"
2
+ require_relative "../../bundler_gemfile"
3
+ require_relative "../../bundler_plugin_index"
4
+ require_relative "../../bundler_plugin_root"
5
+ require_relative "../../ghost_specification"
2
6
 
3
7
  module Gemvault
4
8
  class CLI
5
9
  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
10
+ # Recovers from wrecked bundler plugin state. Two wrecks are repaired:
11
+ #
12
+ # A bundler plugin index entry whose stored path no longer exists.
13
+ # Bundler records absolute paths in .bundle/plugin/index for
14
+ # path-installed plugins. Moving or renaming the source directory
9
15
  # leaves an invalid path behind -- Bundler::Plugin.load_plugin warns
10
16
  # "The following plugin paths don't exist: ..." and silently returns,
11
17
  # leaving @sources[<type>] nil. The next `source X, type: :vault`
12
18
  # crashes inside Bundler::SourceList#add_plugin_source with
13
- # NoMethodError on nil.
19
+ # NoMethodError on nil. Uninstalling clears the broken entry.
20
+ #
21
+ # A ghost specification: an installation record whose gem directory is
22
+ # gone (see Gemvault::GhostSpecification). Bundler's plugin installer
23
+ # trusts the record and validates plugins.rb against the missing
24
+ # directory instead of the copy it just installed, so every
25
+ # `bundle install` fails with MalformattedPlugin (issue #23). Removing
26
+ # the record lets the reinstall see the machine as it is.
27
+ #
28
+ # Re-running bundle install afterwards triggers Bundler to reinstall
29
+ # the plugin against whatever the current Gemfile declares. Run this
30
+ # from the project directory.
14
31
  #
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.
32
+ # A project whose Gemfile is inline has no Gemfile to reinstall from, and
33
+ # keeps its plugins in a root Bundler will not consult unless told to.
34
+ # Both are handled below.
35
+ #
36
+ # The repair is transactional. `bundle install` does more than reinstall
37
+ # the plugin, and that extra work can fail on its own; the index the
38
+ # uninstall clears is snapshotted first and put back when the reinstall
39
+ # never happened, so a failed run leaves the machine as found rather
40
+ # than with no plugin at all (issue #27). Every failure is one line on
41
+ # stderr and exit 1 (issue #24).
18
42
  class Doctor < Command
19
- description "Clear a broken bundler-source-vault plugin index entry and reinstall it"
43
+ description "Repair broken bundler-source-vault plugin state and reinstall the plugin"
44
+
45
+ PLUGIN = "bundler-source-vault".freeze
46
+
47
+ OWNED_GEMS = %w[gemvault bundler-source-vault].freeze
48
+
49
+ PERMISSION_HINT = "(re-run with permissions for that gem home, e.g. sudo gemvault doctor)".freeze
20
50
 
21
51
  def run
22
- system("bundle", "plugin", "uninstall", "bundler-source-vault", exception: true)
23
- exec("bundle", "install")
52
+ begin
53
+ remove_ghost_specifications
54
+ rescue SystemCallError => e
55
+ print_error("#{e.message} #{PERMISSION_HINT}")
56
+ exit(1)
57
+ end
58
+ saved = index.snapshot
59
+ uninstall_plugin
60
+ reinstall_or_explain(saved)
61
+ end
62
+
63
+ private
64
+
65
+ def remove_ghost_specifications
66
+ OWNED_GEMS.flat_map { |name| GhostSpecification.of(name) }.each do |ghost|
67
+ ghost.delete
68
+ puts "Removed ghost specification #{ghost}"
69
+ end
70
+ end
71
+
72
+ def gemfile
73
+ @gemfile ||= BundlerGemfile.new
74
+ end
75
+
76
+ def plugin_root
77
+ @plugin_root ||= BundlerPluginRoot.new(gemfile: gemfile)
78
+ end
79
+
80
+ def index
81
+ @index ||= BundlerPluginIndex.new(plugin_root.consulted)
82
+ end
83
+
84
+ # bundler/inline's own trick: a non-empty BUNDLE_GEMFILE is the whole of
85
+ # what Bundler::Plugin.root consults to prefer a project's plugin
86
+ # directory over the global one, so setting it reaches the entry that
87
+ # needs clearing. The file it names never has to exist.
88
+ def uninstall_env
89
+ return {} unless plugin_root.unreachable?
90
+
91
+ { "BUNDLE_GEMFILE" => "Gemfile" }
92
+ end
93
+
94
+ def uninstall_plugin
95
+ return if system(uninstall_env, "bundle", "plugin", "uninstall", PLUGIN)
96
+
97
+ print_error("bundle plugin uninstall #{PLUGIN} failed")
98
+ exit(1)
99
+ end
100
+
101
+ # `bundle install` with no Gemfile prints its entire usage screen and
102
+ # exits 10, which reads as gemvault itself failing. By this point the
103
+ # repair has already happened; only the reinstall is unavailable.
104
+ def reinstall_or_explain(saved)
105
+ return reinstall(saved) if gemfile.exist?
106
+ return explain_inline_repair if plugin_root.unreachable?
107
+
108
+ puts "No Gemfile here to reinstall from -- re-run gemvault doctor from the"
109
+ puts "project directory to reinstall the plugin."
110
+ end
111
+
112
+ def reinstall(saved)
113
+ return if system("bundle", "install")
114
+ return exit_partially_repaired if index.registered?(PLUGIN)
115
+
116
+ exit_restoring(saved)
117
+ end
118
+
119
+ # The plugin came back before the install died, so the repair stands;
120
+ # only the rest of the bundle is unfinished.
121
+ def exit_partially_repaired
122
+ print_error("bundle install failed; the #{PLUGIN} plugin itself was reinstalled " \
123
+ "-- fix the install error and re-run bundle install")
124
+ exit(1)
125
+ end
126
+
127
+ def exit_restoring(saved)
128
+ index.restore(saved)
129
+ print_error("bundle install failed before reinstalling #{PLUGIN}; restored the " \
130
+ "previous plugin index -- fix the install error and re-run gemvault doctor")
131
+ exit(1)
132
+ end
133
+
134
+ # The local path is named only when it was the uninstall's target --
135
+ # otherwise bundler worked against its global root, and naming this
136
+ # project's path would misreport what happened.
137
+ def explain_inline_repair
138
+ puts "Cleared the #{PLUGIN} plugin index at #{plugin_root.local}."
139
+ puts "No Gemfile here to reinstall from -- an inline gemfile installs the plugin"
140
+ puts "again the next time the script runs."
24
141
  end
25
142
  end
26
143
  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.6".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.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -56,6 +56,9 @@ files:
56
56
  - lib/bundler/plugin/vaulted_gem.rb
57
57
  - lib/gemvault.rb
58
58
  - lib/gemvault/archive_entry.rb
59
+ - lib/gemvault/bundler_gemfile.rb
60
+ - lib/gemvault/bundler_plugin_index.rb
61
+ - lib/gemvault/bundler_plugin_root.rb
59
62
  - lib/gemvault/cli.rb
60
63
  - lib/gemvault/cli/command.rb
61
64
  - lib/gemvault/cli/commands/add.rb
@@ -74,6 +77,7 @@ files:
74
77
  - lib/gemvault/gem_reference/any_version.rb
75
78
  - lib/gemvault/gem_reference/parser.rb
76
79
  - lib/gemvault/gem_reference/specific_version.rb
80
+ - lib/gemvault/ghost_specification.rb
77
81
  - lib/gemvault/manifest.rb
78
82
  - lib/gemvault/tarball.rb
79
83
  - lib/gemvault/tarvault.rb