localvault 1.12.3 → 1.14.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6798f21f6875910fa68c5300a929b4c18e757a9c03251cd71d46bb46e6cd85b7
4
- data.tar.gz: a40a445f7d92baad0645bc9ab2f2a900bda43d09a657e07735c565bb5f705f01
3
+ metadata.gz: 2f77c11508eef73c90760e5a927201f6735a16d836e6890161e89a497e28d917
4
+ data.tar.gz: 15400be786d2ba53112d1b6b48a1de5bd0518903e5578c859677ec68320f2b8b
5
5
  SHA512:
6
- metadata.gz: 45cf703a841eedc73c9b77ac19a92059075dd914c4c1075ff79beca784b61fce4396ffbf51b206315f55c6179c44270d3238a6174c479bc1a2e9e1e9bf4e34d7
7
- data.tar.gz: 661933cfaa2a6f1a69a5459f0e87580912178e1faeeff0becf7823e4fbb007fa63fad8cfa2f7a05d363146309e20ef1ad4b316143d94a541ba1efb78ae3aa340
6
+ metadata.gz: c8fcd7235f9e330cdf7cf2c17528849f7b64b4388e865351ab53c1758b3390d32dd638c2c21e7ba24ff18e7d9c63e7c5e0bd2c66efa87792380d9d8ef12b61b7
7
+ data.tar.gz: c8491e0b0fcb2e588080496254b4fa04195859ad81d7ce66c38d4e13116d20374fa9f96668cc18cf8dc2a0830affb58ea59df84ed6eb1531c817942f364c54e3
@@ -0,0 +1,58 @@
1
+ module LocalVault
2
+ class CLI
3
+ # Finds a Rails app's credentials keys so they can be stored in the vault
4
+ # and injected as RAILS_MASTER_KEY instead of living in a file.
5
+ #
6
+ # Reads only. Removing the key files is the operator's call — this reports
7
+ # what is still on disk and leaves it alone.
8
+ class RailsImport
9
+ Key = Struct.new(:environment, :path, :vault_key, :value, keyword_init: true)
10
+
11
+ MASTER_KEY_PATH = "config/master.key".freeze
12
+ CREDENTIALS_PATH = "config/credentials.yml.enc".freeze
13
+
14
+ def initialize(root = Dir.pwd)
15
+ @root = root
16
+ end
17
+
18
+ def rails_app?
19
+ File.exist?(File.join(@root, CREDENTIALS_PATH)) ||
20
+ File.exist?(File.join(@root, MASTER_KEY_PATH)) ||
21
+ Dir.exist?(File.join(@root, "config/credentials"))
22
+ end
23
+
24
+ # Every key file this app has: the default master key plus one per
25
+ # environment (config/credentials/production.key).
26
+ def keys
27
+ [master_key, *environment_keys].compact
28
+ end
29
+
30
+ private
31
+
32
+ def master_key
33
+ path = File.join(@root, MASTER_KEY_PATH)
34
+ return nil unless File.exist?(path)
35
+
36
+ value = File.read(path).strip
37
+ return nil if value.empty?
38
+
39
+ Key.new(environment: nil, path: MASTER_KEY_PATH, vault_key: "rails.master_key", value: value)
40
+ end
41
+
42
+ def environment_keys
43
+ Dir.glob(File.join(@root, "config/credentials/*.key")).sort.filter_map do |path|
44
+ environment = File.basename(path, ".key")
45
+ value = File.read(path).strip
46
+ next if value.empty?
47
+
48
+ Key.new(
49
+ environment: environment,
50
+ path: "config/credentials/#{environment}.key",
51
+ vault_key: "rails.#{environment}_key",
52
+ value: value
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -116,14 +116,14 @@ module LocalVault
116
116
  # disappearing from help.
117
117
  HELP_SECTIONS = [
118
118
  ["GETTING STARTED", %w[login config init demo]],
119
- ["SECRETS", %w[set get show reveal groups list delete import env exec]],
119
+ ["SECRETS", %w[set get show reveal groups list delete import env exec rails]],
120
120
  ["VAULT MANAGEMENT", %w[vaults switch rekey unlock lock reset rename copy]],
121
121
  ["SYNC (requires localvault login)", %w[sync]],
122
122
  ["TEAM SHARING (requires localvault login)", %w[dashboard verify add remove team]],
123
123
  ["KEYS (X25519 identity for vault sharing)", %w[keys identity]],
124
124
  ["AI / MCP", %w[install-mcp mcp guard]],
125
125
  ["LEGACY SHARING (pre-v1.2 direct share, still works)", %w[keygen share receive revoke]],
126
- ["OTHER", %w[logout version doctor help]]
126
+ ["OTHER", %w[logout version doctor upgrade help]]
127
127
  ].freeze
128
128
 
129
129
  # One row per plain command; registered subcommands (sync, team, keys,
@@ -807,6 +807,7 @@ module LocalVault
807
807
  require_relative "cli/sync"
808
808
  require_relative "cli/guard"
809
809
  require_relative "cli/identity_cmd"
810
+ require_relative "cli/rails_import"
810
811
 
811
812
  # Thor 1.5 injects a `tree` command into every class. Inside a namespace it
812
813
  # lists under a name that isn't even callable (`identity_command tree`), and
@@ -1649,6 +1650,121 @@ module LocalVault
1649
1650
  end
1650
1651
  end
1651
1652
 
1653
+ desc "rails", "Import a Rails app's credentials keys into the vault"
1654
+ long_desc <<~DESC
1655
+ Store this Rails app's credentials keys in the vault so you can run it
1656
+ with no key file on disk. Rails reads ENV["RAILS_MASTER_KEY"] whenever
1657
+ config/master.key is absent, so injecting that one variable is enough.
1658
+
1659
+ \x05 localvault rails # import config/master.key (and per-env keys)
1660
+ \x05 localvault rails --check # show what would be imported
1661
+
1662
+ Then run your app with the key injected, never written anywhere:
1663
+
1664
+ \x05 localvault exec --profile rails -- bin/rails server
1665
+ \x05 localvault exec --profile rails -- bin/rails credentials:edit
1666
+
1667
+ For a per-environment key (config/credentials/production.key), map it —
1668
+ Rails reads no variable other than RAILS_MASTER_KEY:
1669
+
1670
+ \x05 localvault exec --map rails.production_key=RAILS_MASTER_KEY -- bin/rails console
1671
+
1672
+ Key files are left exactly as they are. Deleting them is your call.
1673
+ DESC
1674
+ method_option :check, type: :boolean, default: false, desc: "List what would be imported without writing"
1675
+ def rails
1676
+ importer = RailsImport.new
1677
+ unless importer.rails_app?
1678
+ abort_with "No Rails app here — expected config/credentials.yml.enc or config/master.key in #{Dir.pwd}"
1679
+ return CommandStatus.error
1680
+ end
1681
+
1682
+ keys = importer.keys
1683
+ if keys.empty?
1684
+ $stdout.puts "No credentials key files found (config/master.key, config/credentials/*.key)."
1685
+ $stdout.puts "Nothing to import — the app may already run from RAILS_MASTER_KEY."
1686
+ return CommandStatus.ok
1687
+ end
1688
+
1689
+ if options[:check]
1690
+ $stdout.puts "Would import into vault '#{options[:vault] || Config.default_vault}':"
1691
+ keys.each { |key| $stdout.puts " #{key.path} -> #{key.vault_key}" }
1692
+ return CommandStatus.ok
1693
+ end
1694
+
1695
+ vault = open_vault!
1696
+ keys.each do |key|
1697
+ vault.set(key.vault_key, key.value)
1698
+ $stdout.puts "Imported #{key.path} -> #{key.vault_key}"
1699
+ end
1700
+
1701
+ $stdout.puts
1702
+ $stdout.puts "Run without a key file on disk:"
1703
+ $stdout.puts " localvault exec --profile rails -- bin/rails server"
1704
+ keys.select(&:environment).each do |key|
1705
+ $stdout.puts " localvault exec --map #{key.vault_key}=RAILS_MASTER_KEY -- bin/rails console # #{key.environment}"
1706
+ end
1707
+
1708
+ $stdout.puts
1709
+ $stdout.puts "Note: the key files are still on disk (#{keys.map(&:path).join(", ")})."
1710
+ $stdout.puts "They keep working until you remove them — that's your call, not ours."
1711
+ CommandStatus.ok
1712
+ end
1713
+
1714
+ desc "upgrade", "Upgrade localvault using whichever method installed it"
1715
+ long_desc <<~DESC
1716
+ Detect how this copy of localvault was installed and run the matching
1717
+ upgrade — Homebrew, RubyGems, or the install script.
1718
+
1719
+ \x05 localvault upgrade # detect and upgrade
1720
+ \x05 localvault upgrade --check # show what would run, change nothing
1721
+
1722
+ If more than one localvault is on PATH, an old copy can shadow the new one
1723
+ and the upgrade looks like it did nothing. This refuses to guess in that
1724
+ case and points you at `localvault doctor`.
1725
+ DESC
1726
+ method_option :check, type: :boolean, default: false, desc: "Print the upgrade command without running it"
1727
+ def upgrade
1728
+ paths = localvault_paths
1729
+ if paths.length > 1
1730
+ $stderr.puts "Error: multiple localvault executables are on PATH:"
1731
+ paths.each_with_index { |path, index| $stderr.puts " #{index + 1}. #{path}" }
1732
+ $stderr.puts "\nUpgrading now would leave a stale copy shadowing the new one."
1733
+ $stderr.puts "Run `localvault doctor`, remove the copies you don't want, then retry."
1734
+ return CommandStatus.error
1735
+ end
1736
+
1737
+ method, command = upgrade_method_for(paths.first)
1738
+ if method == :unknown
1739
+ $stderr.puts "Error: cannot tell how localvault was installed#{" (#{paths.first})" if paths.first}."
1740
+ $stderr.puts "Upgrade with whichever you used:"
1741
+ $stderr.puts " brew upgrade inventlist/tap/localvault"
1742
+ $stderr.puts " gem update localvault"
1743
+ $stderr.puts " curl -sSL https://inventlist.com/tools/localvault/install.sh | sh"
1744
+ return CommandStatus.error
1745
+ end
1746
+
1747
+ $stdout.puts "Installed via #{method} (#{paths.first})"
1748
+ $stdout.puts "Current version: #{VERSION}"
1749
+ $stdout.puts "Command: #{command}"
1750
+
1751
+ if options[:check]
1752
+ $stdout.puts "\n--check: nothing was run."
1753
+ return CommandStatus.ok
1754
+ end
1755
+
1756
+ $stdout.puts
1757
+ unless system(command)
1758
+ $stderr.puts "\nError: upgrade command failed. Run it yourself to see the full output:"
1759
+ $stderr.puts " #{command}"
1760
+ return CommandStatus.error
1761
+ end
1762
+
1763
+ $stdout.puts
1764
+ $stdout.puts "Done. Verify with: localvault version"
1765
+ CommandStatus.ok
1766
+ end
1767
+
1652
1768
  def self.exit_on_failure?
1653
1769
  false
1654
1770
  end
@@ -2169,6 +2285,38 @@ module LocalVault
2169
2285
  path.start_with?("/opt/homebrew/bin/", "/usr/local/bin/")
2170
2286
  end
2171
2287
 
2288
+ # Which installer put this executable here, and how to upgrade it.
2289
+ #
2290
+ # The path alone can't decide: Homebrew and install.sh both land in
2291
+ # /usr/local/bin. Both write a wrapper naming their gem prefix, so read
2292
+ # that instead — a Cellar path means brew, ~/.localvault/runtime means the
2293
+ # install script.
2294
+ UPGRADE_COMMANDS = {
2295
+ brew: "brew upgrade inventlist/tap/localvault",
2296
+ gem: "gem update localvault",
2297
+ script: "curl -sSL https://inventlist.com/tools/localvault/install.sh | sh"
2298
+ }.freeze
2299
+
2300
+ def upgrade_method_for(path)
2301
+ method = detect_install_method(path)
2302
+ [method, UPGRADE_COMMANDS[method]]
2303
+ end
2304
+
2305
+ def detect_install_method(path)
2306
+ return :unknown if path.nil?
2307
+ return :brew if File.symlink?(path) && File.readlink(path).include?("/Cellar/")
2308
+
2309
+ wrapper = File.read(path, 2048)
2310
+ return :brew if wrapper.include?("/Cellar/")
2311
+ return :script if wrapper.include?("/.localvault/runtime")
2312
+ return :gem if path.include?("/shims/") || path.include?("/gems/")
2313
+
2314
+ :unknown
2315
+ rescue SystemCallError, ArgumentError
2316
+ # Binary or unreadable: fall back to the one thing the path does tell us.
2317
+ path.include?("/shims/") || path.include?("/gems/") ? :gem : :unknown
2318
+ end
2319
+
2172
2320
  def cursor_settings_path
2173
2321
  File.expand_path("~/.cursor/mcp.json")
2174
2322
  end
@@ -17,9 +17,24 @@ module LocalVault
17
17
  "AWS_IAM.secret_access_key" => "AWS_SECRET_ACCESS_KEY",
18
18
  "AWS_IAM.session_token" => "AWS_SESSION_TOKEN"
19
19
  }
20
+ },
21
+ # Rails reads its credentials key from ENV["RAILS_MASTER_KEY"] when
22
+ # config/master.key is absent (railties: encrypted(..., env_key:
23
+ # "RAILS_MASTER_KEY")), so injecting that one variable is enough to run a
24
+ # Rails app with no key file on disk.
25
+ "rails" => {
26
+ only: ["rails.*"],
27
+ map: { "rails.master_key" => "RAILS_MASTER_KEY" }
20
28
  }
21
29
  }.freeze
22
30
 
31
+ # Per-environment credentials (config/credentials/production.key) still have
32
+ # to arrive as RAILS_MASTER_KEY — Rails reads no other variable. This builds
33
+ # the mapping for one environment.
34
+ def self.rails_environment_mapping(environment)
35
+ { "rails.#{environment}_key" => "RAILS_MASTER_KEY" }
36
+ end
37
+
23
38
  def self.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil)
24
39
  profile_config = profile_config(profile)
25
40
  selectors = parse_selectors(only) || profile_config[:only]
@@ -4,7 +4,7 @@ module LocalVault
4
4
 
5
5
  SEGMENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
6
6
  VAULT_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\z/
7
- PROFILES = %w[aws].freeze
7
+ PROFILES = %w[aws rails].freeze
8
8
 
9
9
  module_function
10
10
 
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.12.3"
2
+ VERSION = "1.14.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: localvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.3
4
+ version: 1.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -115,6 +115,7 @@ files:
115
115
  - lib/localvault/cli/help_shell.rb
116
116
  - lib/localvault/cli/identity_cmd.rb
117
117
  - lib/localvault/cli/keys.rb
118
+ - lib/localvault/cli/rails_import.rb
118
119
  - lib/localvault/cli/sync.rb
119
120
  - lib/localvault/cli/team.rb
120
121
  - lib/localvault/cli/team_helpers.rb