localvault 1.6.2 → 1.8.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.
@@ -2,10 +2,96 @@ require "thor"
2
2
  require "io/console"
3
3
  require "base64"
4
4
  require "lipgloss"
5
+ require_relative "env_projection"
6
+ require_relative "key_lookup"
5
7
  require_relative "session_cache"
8
+ require_relative "vault_resolver"
9
+ require_relative "group_catalog"
6
10
 
7
11
  module LocalVault
8
12
  class CLI < Thor
13
+ USAGE_EXIT_STATUS = 1
14
+ GROUP_ALL_SENTINEL = "\0localvault-all-groups"
15
+ GROUP_OFF_SENTINEL = "\0localvault-groups-off"
16
+ CommandStatus = Data.define(:code) do
17
+ def self.ok
18
+ new(0)
19
+ end
20
+
21
+ def self.error
22
+ new(1)
23
+ end
24
+ end
25
+ class GroupSaveError < Thor::Error
26
+ attr_reader :kind, :candidates
27
+
28
+ def initialize(kind, candidates: [])
29
+ @kind = kind
30
+ @candidates = candidates
31
+ super("group save failed")
32
+ end
33
+
34
+ def exit_status
35
+ 1
36
+ end
37
+ end
38
+ class GroupSelectionError < Thor::Error
39
+ attr_reader :kind, :query, :candidates
40
+
41
+ def initialize(kind, query:, candidates: [])
42
+ @kind = kind
43
+ @query = query
44
+ @candidates = candidates
45
+ super("group selection failed")
46
+ end
47
+
48
+ def exit_status
49
+ 1
50
+ end
51
+ end
52
+
53
+ def self.start(given_args = ARGV, config = {})
54
+ require_relative "cli/error_presenter"
55
+ config[:shell] ||= Thor::Base.shell.new
56
+ result = dispatch(nil, normalize_legacy_group_option(given_args.dup), nil, config)
57
+ result.is_a?(CommandStatus) ? result.code : 0
58
+ rescue Thor::Error => error
59
+ ErrorPresenter.new(self, given_args).render(error)
60
+ error.respond_to?(:exit_status) ? error.exit_status : USAGE_EXIT_STATUS
61
+ rescue Errno::EPIPE
62
+ 0
63
+ end
64
+
65
+ def self.normalize_legacy_group_option(arguments)
66
+ command = arguments.first
67
+ matches = all_commands.keys.select { |name| name.start_with?(command.to_s) }
68
+ return arguments unless command == "show" || matches == ["show"]
69
+
70
+ normalized = []
71
+ index = 0
72
+ while index < arguments.length
73
+ argument = arguments[index]
74
+ if argument == "--"
75
+ normalized.concat(arguments[index..])
76
+ break
77
+ end
78
+ if argument == "--group" && arguments[index + 1]&.match?(/\A(?:true|false|t|f)\z/i)
79
+ enabled = arguments[index + 1].match?(/\A(?:true|t)\z/i)
80
+ normalized << "--group=#{enabled ? GROUP_ALL_SENTINEL : GROUP_OFF_SENTINEL}"
81
+ index += 2
82
+ next
83
+ end
84
+
85
+ case argument
86
+ when /\A--group=(?:true|t)\z/i then normalized << "--group=#{GROUP_ALL_SENTINEL}"
87
+ when /\A--group=(?:false|f)\z/i, "--no-group", "--skip-group" then normalized << "--group=#{GROUP_OFF_SENTINEL}"
88
+ else normalized << argument
89
+ end
90
+ index += 1
91
+ end
92
+ normalized
93
+ end
94
+
9
95
  class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
10
96
 
11
97
  def self.help(shell, subcommand = false)
@@ -20,8 +106,10 @@ module LocalVault
20
106
  shell.say ""
21
107
  shell.say "SECRETS"
22
108
  shell.say " localvault set KEY VALUE Store a secret"
109
+ shell.say " localvault set --group G K V Store a secret inside group G"
23
110
  shell.say " localvault get KEY Retrieve a secret"
24
111
  shell.say " localvault show Display all secrets (masked by default)"
112
+ shell.say " localvault groups [QUERY] List or search stored groups (names only)"
25
113
  shell.say " localvault list List secret key names"
26
114
  shell.say " localvault delete KEY Remove a secret"
27
115
  shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
@@ -68,6 +156,8 @@ module LocalVault
68
156
  shell.say "AI / MCP"
69
157
  shell.say " localvault install-mcp Configure MCP server in your AI tool"
70
158
  shell.say " localvault mcp Start MCP server (stdio)"
159
+ shell.say " localvault mcp --check Check setup and active-vault readiness"
160
+ shell.say " Agents: list names, then use localvault_build_exec for safe injection"
71
161
  shell.say ""
72
162
  shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
73
163
  shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
@@ -120,14 +210,38 @@ module LocalVault
120
210
  \x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
121
211
  \x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
122
212
 
213
+ GUIDED GROUP SAVE (same storage, easier to discover):
214
+ \x05 localvault set --group GROUP KEY VALUE
215
+ \x05 localvault set GROUP.KEY VALUE
216
+ \x05 localvault groups [QUERY]
217
+
123
218
  The dot separates project from key name. One vault can hold many projects.
124
219
  Use `localvault show -p platepose -v vault` to view a single project.
125
220
  Use `localvault import` to bulk-load from a .env, .json, or .yml file.
126
221
  DESC
222
+ method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
127
223
  def set(key, value)
128
224
  vault = open_vault!
129
- vault.set(key, value)
130
- $stdout.puts "Set #{key} in vault '#{vault.name}'"
225
+ if options[:group]
226
+ group = canonical_group_name(vault, options[:group])
227
+ validate_group_segment!(group)
228
+ validate_group_segment!(key)
229
+ raise GroupSaveError, :collision if vault.all.key?(group) && !vault.all[group].is_a?(Hash)
230
+ vault.set("#{group}.#{key}", value)
231
+ $stdout.puts "Set #{key} in group `#{group}` in vault `#{vault.name}`."
232
+ $stdout.puts
233
+ $stdout.puts "Stored as:"
234
+ $stdout.puts " #{group}.#{key}"
235
+ else
236
+ vault.set(key, value)
237
+ $stdout.puts "Set #{key} in vault '#{vault.name}'"
238
+ end
239
+ rescue Vault::InvalidKeyName => e
240
+ raise GroupSaveError, :invalid if options[:group]
241
+ abort_with e.message
242
+ rescue RuntimeError => e
243
+ raise GroupSaveError, :collision if options[:group]
244
+ abort_with e.message
131
245
  end
132
246
 
133
247
  desc "get KEY", "Retrieve a secret value by key"
@@ -146,28 +260,18 @@ module LocalVault
146
260
  DESC
147
261
  def get(key)
148
262
  vault = open_vault!
149
- value = vault.get(key)
150
-
151
- if value.nil?
152
- # Fall back to case-insensitive substring match
153
- all_keys = vault.list
154
- matches = all_keys.select { |k| k.downcase.include?(key.downcase) }
155
-
156
- if matches.size == 1
157
- value = vault.get(matches.first)
158
- $stdout.puts value
159
- return
160
- elsif matches.size > 1
161
- $stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
162
- matches.sort.each { |k| $stderr.puts " #{k}" }
163
- return
164
- else
165
- abort_with "Key '#{key}' not found in vault '#{vault.name}'"
166
- return
167
- end
263
+ lookup = KeyLookup.lookup(vault, key)
264
+
265
+ if lookup.exact?
266
+ $stdout.puts lookup.value
267
+ elsif lookup.single_match?
268
+ $stdout.puts vault.get(lookup.matches.first)
269
+ elsif lookup.multiple_matches?
270
+ $stderr.puts "Error: Multiple keys match '#{key}'. Be more specific:"
271
+ lookup.matches.each { |k| $stderr.puts " #{k}" }
272
+ else
273
+ abort_with "Key '#{key}' not found in vault '#{vault.name}'"
168
274
  end
169
-
170
- $stdout.puts value
171
275
  end
172
276
 
173
277
  desc "list", "List all secret keys in the vault"
@@ -192,6 +296,30 @@ module LocalVault
192
296
  vault.list.each { |key| $stdout.puts key }
193
297
  end
194
298
 
299
+ desc "groups [QUERY]", "List or search stored secret groups without revealing values"
300
+ long_desc <<~DESC
301
+ Discover dot-notation namespaces and flat-key prefix groups.
302
+
303
+ \x05 localvault groups
304
+ \x05 localvault groups str
305
+ \x05 localvault show --group STRIPE
306
+ \x05 localvault set --group STRIPE API_KEY VALUE
307
+ DESC
308
+ def groups(query = nil)
309
+ vault = open_vault!
310
+ matches = GroupCatalog.new(vault.all).search(query)
311
+ if matches.empty?
312
+ $stdout.puts "No groups match #{query}"
313
+ return
314
+ end
315
+
316
+ heading = query ? "Groups matching `#{query}`" : "Groups"
317
+ $stdout.puts "#{heading} in vault `#{vault.name}`:"
318
+ $stdout.puts
319
+ $stdout.printf(" %-20s %-6s %s\n", "Group", "Keys", "Kind")
320
+ matches.each { |group| $stdout.printf(" %-20s %-6d %s\n", group.name, group.count, group.kind) }
321
+ end
322
+
195
323
  desc "delete KEY", "Remove a secret or entire project group"
196
324
  long_desc <<~DESC
197
325
  Delete a single key or an entire project group.
@@ -231,13 +359,25 @@ module LocalVault
231
359
  \x05 eval $(localvault env -v intellectaco)
232
360
  \x05 # → export PLATEPOSE__DATABASE_URL=... export INVENTLIST__DATABASE_URL=...
233
361
 
362
+ SCOPED EXPORTS:
363
+ \x05 localvault env --only AWS_IAM.*
364
+ \x05 localvault env --only AWS_IAM.*,AWS_SES.* --except AWS_SES.smtp_password
365
+ \x05 localvault env --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID
366
+ \x05 localvault env --profile aws
367
+
234
368
  Use `localvault exec` to inject directly into a subprocess without eval.
235
369
  DESC
236
370
  method_option :project, aliases: "-p", type: :string, desc: "Export only this project group (no prefix)"
371
+ method_option :only, type: :string, desc: "Export only exact keys or namespaces (KEY,GROUP.*)"
372
+ method_option :except, type: :string, desc: "Exclude exact keys or namespaces after --only"
373
+ method_option :map, type: :string, desc: "Map vault keys to env vars (KEY=ENV_NAME)"
374
+ method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
237
375
  def env
238
376
  vault = open_vault!
239
377
  skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
240
- $stdout.puts vault.export_env(project: options[:project], on_skip: skip_warn)
378
+ $stdout.puts vault.export_env(**env_projection_options(on_skip: skip_warn))
379
+ rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
380
+ abort_with e.message
241
381
  end
242
382
 
243
383
  desc "exec -- CMD", "Run a command with secrets injected as environment variables"
@@ -256,13 +396,24 @@ module LocalVault
256
396
  TEAM VAULT — all projects (keys prefixed to avoid collisions):
257
397
  \x05 localvault exec -v intellectaco -- your-script
258
398
  \x05 # → PLATEPOSE__DATABASE_URL, INVENTLIST__DATABASE_URL, etc.
399
+
400
+ SCOPED INJECTION:
401
+ \x05 localvault exec --only AWS_IAM.* -- aws sts get-caller-identity
402
+ \x05 localvault exec --profile aws -- aws s3 ls
403
+ \x05 localvault exec --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID -- env
259
404
  DESC
260
405
  method_option :project, aliases: "-p", type: :string, desc: "Inject only this project group (no prefix)"
406
+ method_option :only, type: :string, desc: "Inject only exact keys or namespaces (KEY,GROUP.*)"
407
+ method_option :except, type: :string, desc: "Exclude exact keys or namespaces after --only"
408
+ method_option :map, type: :string, desc: "Map vault keys to env vars (KEY=ENV_NAME)"
409
+ method_option :profile, type: :string, desc: "Apply a built-in env mapping profile (aws)"
261
410
  def exec(*cmd)
262
411
  vault = open_vault!
263
412
  skip_warn = ->(k) { $stderr.puts "Warning: skipping unsafe key '#{k}'" }
264
- env_vars = vault.env_hash(project: options[:project], on_skip: skip_warn)
413
+ env_vars = vault.env_hash(**env_projection_options(on_skip: skip_warn))
265
414
  Kernel.exec(env_vars, *cmd)
415
+ rescue EnvProjection::InvalidMapping, EnvProjection::UnknownProfile => e
416
+ abort_with e.message
266
417
  end
267
418
 
268
419
  desc "vaults", "List all vaults with secret counts"
@@ -334,14 +485,15 @@ module LocalVault
334
485
  \x05 localvault show -p platepose -v intellectaco # one project only
335
486
  \x05 localvault show -p platepose -v intellectaco --reveal
336
487
  DESC
337
- method_option :group, type: :boolean, default: false, desc: "Group flat keys by common prefix"
488
+ method_option :group, type: :string, lazy_default: GROUP_ALL_SENTINEL, desc: "Show all groups or one group by name"
338
489
  method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
339
490
  method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
340
491
  def show
341
492
  vault = open_vault!
342
493
  secrets = vault.all
343
494
 
344
- if secrets.empty?
495
+ named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
496
+ if secrets.empty? && !named_group_query
345
497
  $stdout.puts "No secrets in vault '#{vault.name}'."
346
498
  return
347
499
  end
@@ -353,7 +505,17 @@ module LocalVault
353
505
  return
354
506
  end
355
507
  render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
356
- elsif options[:group] || secrets.values.any? { |v| v.is_a?(Hash) }
508
+ elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
509
+ match = GroupCatalog.new(secrets).resolve(options[:group])
510
+ if match.group
511
+ entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
512
+ render_table(entries, "#{vault.name}/#{match.group.name}", reveal: options[:reveal])
513
+ elsif match.kind == :ambiguous
514
+ raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
515
+ else
516
+ raise GroupSelectionError.new(:absent, query: options[:group])
517
+ end
518
+ elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
357
519
  render_grouped_table(secrets, vault.name, reveal: options[:reveal])
358
520
  else
359
521
  render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
@@ -452,8 +614,44 @@ module LocalVault
452
614
  end
453
615
 
454
616
  desc "mcp", "Start MCP server (stdio)"
617
+ long_desc <<~DESC
618
+ Start LocalVault's stdio MCP server for an AI client.
619
+
620
+ Install and verify:
621
+ \x05 localvault install-mcp [claude-code|cursor|windsurf]
622
+ \x05 localvault mcp --check
623
+ \x05 localvault show
624
+
625
+ Do not run `localvault mcp` directly to test it: stdio servers wait for
626
+ JSON-RPC input and therefore appear idle. Use `--check`, then restart the
627
+ configured AI client.
628
+
629
+ Agents should call `list_secrets`, then `localvault_build_exec` to inject
630
+ secrets into a subprocess. Plaintext `get_secret` requires the explicit
631
+ `allow_plaintext: true` acknowledgement.
632
+ DESC
633
+ method_option :check, type: :boolean, default: false, desc: "Check installation and active-vault readiness, then exit"
455
634
  def mcp
456
- require "localvault/mcp/server"
635
+ if options[:check]
636
+ require_relative "mcp/tools"
637
+ status = VaultResolver.readiness_status(options[:vault])
638
+ ready = status["active_vault_unlocked"]
639
+ tool_names = MCP::Tools::DEFINITIONS.map { |definition| definition.fetch("name") }
640
+ $stdout.puts "LocalVault #{VERSION}"
641
+ $stdout.puts "Home: #{Config.root_path}"
642
+ $stdout.puts "MCP readiness: #{ready ? "ready" : "locked"}"
643
+ $stdout.puts "Active vault: #{status["active_vault"]} (#{status["active_vault_source"]})"
644
+ $stdout.puts "Vault session: #{ready ? "available" : "unlock with `localvault show`"}"
645
+ $stdout.puts "MCP tools: #{tool_names.join(", ")}"
646
+ $stdout.puts "Plaintext gate: enabled"
647
+ $stdout.puts "Server instructions: enabled"
648
+ $stdout.puts
649
+ $stdout.puts "Safe agent workflow: list_secrets → localvault_build_exec → run the generated command"
650
+ $stdout.puts "Plaintext retrieval is opt-in with allow_plaintext: true."
651
+ return ready ? CommandStatus.ok : CommandStatus.error
652
+ end
653
+
654
+ require_relative "mcp/server"
457
655
  MCP::Server.new.start
458
656
  end
459
657
 
@@ -466,8 +664,10 @@ module LocalVault
466
664
  cursor Adds to ~/.cursor/mcp.json
467
665
  windsurf Adds to ~/.codeium/windsurf/mcp_config.json
468
666
 
469
- The MCP server uses whichever vault is your current default (localvault switch).
470
- Unlock the vault once with `localvault show`, then the AI tool picks it up via Keychain.
667
+ The MCP server uses the same active vault rules as the CLI: explicit vault,
668
+ then LOCALVAULT_VAULT, then the configured default from `localvault switch`.
669
+ Unlock with `localvault show` or `localvault unlock`; `localvault lock`
670
+ revokes access without restarting the AI tool.
471
671
  DESC
472
672
  def install_mcp(client = "claude-code")
473
673
  case client.downcase
@@ -1264,7 +1464,7 @@ module LocalVault
1264
1464
  end
1265
1465
 
1266
1466
  def self.exit_on_failure?
1267
- true
1467
+ false
1268
1468
  end
1269
1469
 
1270
1470
  no_commands do
@@ -1289,6 +1489,23 @@ module LocalVault
1289
1489
 
1290
1490
  private
1291
1491
 
1492
+ def canonical_group_name(vault, supplied)
1493
+ groups = GroupCatalog.new(vault.all).groups
1494
+ return supplied if groups.any? { |group| group.name == supplied }
1495
+
1496
+ insensitive = groups.select { |group| group.name.casecmp?(supplied) }
1497
+ return insensitive.first.name if insensitive.one?
1498
+ raise GroupSaveError.new(:ambiguous, candidates: insensitive.map(&:name)) if insensitive.length > 1
1499
+
1500
+ supplied
1501
+ end
1502
+
1503
+ def validate_group_segment!(segment)
1504
+ unless segment.match?(Vault::KEY_SEGMENT_PATTERN) && segment.length <= 128
1505
+ raise GroupSaveError, :invalid
1506
+ end
1507
+ end
1508
+
1292
1509
  # ── Demo data ──────────────────────────────────────────────────
1293
1510
  DEMO_DATA = {
1294
1511
  "default" => {
@@ -1514,7 +1731,18 @@ module LocalVault
1514
1731
  end
1515
1732
 
1516
1733
  def resolve_vault_name
1517
- options[:vault] || Config.default_vault
1734
+ VaultResolver.active_vault_name(options[:vault]).first
1735
+ end
1736
+
1737
+ def env_projection_options(on_skip:)
1738
+ {
1739
+ project: options[:project],
1740
+ only: options[:only],
1741
+ except: options[:except],
1742
+ map: options[:map],
1743
+ profile: options[:profile],
1744
+ on_skip: on_skip
1745
+ }
1518
1746
  end
1519
1747
 
1520
1748
  def open_vault_by_name!(vault_name)
@@ -1665,9 +1893,11 @@ module LocalVault
1665
1893
  def print_next_steps(client_name)
1666
1894
  $stdout.puts ""
1667
1895
  $stdout.puts "Next steps:"
1668
- $stdout.puts " 1. Restart #{client_name}"
1669
- $stdout.puts " 2. Unlock your vault once: localvault show"
1670
- $stdout.puts " 3. The AI can now access secrets from your default vault"
1896
+ $stdout.puts " 1. Unlock your vault once: localvault show"
1897
+ $stdout.puts " 2. Verify readiness: localvault mcp --check"
1898
+ $stdout.puts " 3. Restart #{client_name}"
1899
+ $stdout.puts " 4. Ask the agent to list names, then use localvault_build_exec"
1900
+ $stdout.puts " for process injection. Plaintext get_secret is explicit opt-in."
1671
1901
  $stdout.puts " Switch vaults: localvault switch <vault>"
1672
1902
  end
1673
1903
 
@@ -0,0 +1,138 @@
1
+ require_relative "input_validation"
2
+
3
+ module LocalVault
4
+ module EnvProjection
5
+ class InvalidMapping < StandardError; end
6
+ class UnknownProfile < StandardError; end
7
+
8
+ Entry = Struct.new(:key, :env_name, :value, keyword_init: true)
9
+
10
+ ENV_NAME_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
11
+
12
+ PROFILES = {
13
+ "aws" => {
14
+ only: ["AWS_IAM.*"],
15
+ map: {
16
+ "AWS_IAM.access_key_id" => "AWS_ACCESS_KEY_ID",
17
+ "AWS_IAM.secret_access_key" => "AWS_SECRET_ACCESS_KEY",
18
+ "AWS_IAM.session_token" => "AWS_SESSION_TOKEN"
19
+ }
20
+ }
21
+ }.freeze
22
+
23
+ def self.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil)
24
+ profile_config = profile_config(profile)
25
+ selectors = parse_selectors(only) || profile_config[:only]
26
+ exclusions = parse_selectors(except) || []
27
+ mappings = profile_config[:map].merge(parse_map(map))
28
+
29
+ flatten(secrets, project: project, on_skip: on_skip)
30
+ .select { |entry| include_entry?(entry.key, selectors) }
31
+ .reject { |entry| selector_match?(entry.key, exclusions) }
32
+ .map { |entry| apply_mapping(entry, mappings, on_skip: on_skip) }
33
+ .compact
34
+ end
35
+
36
+ def self.parse_selectors(value)
37
+ values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
38
+ selectors = values.map(&:strip).reject(&:empty?)
39
+ selectors.each { |selector| InputValidation.selector!(selector) }
40
+ selectors.empty? ? nil : selectors
41
+ rescue InputValidation::InvalidInput => e
42
+ raise InvalidMapping, e.message
43
+ end
44
+
45
+ def self.parse_map(value)
46
+ Array(value).compact.flat_map { |v| v.to_s.split(",") }.each_with_object({}) do |pair, hash|
47
+ next if pair.strip.empty?
48
+
49
+ key, env_name = pair.split("=", 2).map(&:strip)
50
+ raise InvalidMapping, "Invalid map '#{pair}'. Use KEY=ENV_NAME" if key.to_s.empty? || env_name.to_s.empty?
51
+ InputValidation.mapping!(key, env_name)
52
+
53
+ hash[key] = env_name
54
+ end
55
+ rescue InputValidation::InvalidInput => e
56
+ raise InvalidMapping, e.message
57
+ end
58
+
59
+ def self.profile_config(profile)
60
+ return { only: nil, map: {} } if profile.nil? || profile.to_s.empty?
61
+
62
+ PROFILES.fetch(profile.to_s) do
63
+ raise UnknownProfile, "Unknown env profile '#{profile}'"
64
+ end
65
+ end
66
+
67
+ def self.flatten(secrets, project:, on_skip:)
68
+ if project
69
+ group = secrets[project]
70
+ return [] unless group.is_a?(Hash)
71
+
72
+ group.filter_map do |key, value|
73
+ if safe_env_name?(key)
74
+ Entry.new(key: key, env_name: key, value: value.to_s)
75
+ else
76
+ on_skip&.call(key)
77
+ nil
78
+ end
79
+ end
80
+ else
81
+ secrets.flat_map do |key, value|
82
+ if value.is_a?(Hash)
83
+ flatten_group(key, value, on_skip: on_skip)
84
+ elsif safe_env_name?(key)
85
+ [Entry.new(key: key, env_name: key, value: value.to_s)]
86
+ else
87
+ on_skip&.call(key)
88
+ []
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def self.flatten_group(group, pairs, on_skip:)
95
+ unless safe_env_name?(group)
96
+ on_skip&.call(group)
97
+ return []
98
+ end
99
+
100
+ pairs.filter_map do |key, value|
101
+ if safe_env_name?(key)
102
+ Entry.new(key: "#{group}.#{key}", env_name: "#{group.upcase}__#{key}", value: value.to_s)
103
+ else
104
+ on_skip&.call("#{group}.#{key}")
105
+ nil
106
+ end
107
+ end
108
+ end
109
+
110
+ def self.include_entry?(key, selectors)
111
+ selectors.nil? || selector_match?(key, selectors)
112
+ end
113
+
114
+ def self.selector_match?(key, selectors)
115
+ selectors.any? do |selector|
116
+ if selector.end_with?(".*")
117
+ key.start_with?("#{selector.delete_suffix(".*")}.")
118
+ else
119
+ key == selector
120
+ end
121
+ end
122
+ end
123
+
124
+ def self.apply_mapping(entry, mappings, on_skip:)
125
+ mapped_name = mappings.fetch(entry.key, entry.env_name)
126
+ unless safe_env_name?(mapped_name)
127
+ on_skip&.call(entry.key)
128
+ return nil
129
+ end
130
+
131
+ Entry.new(key: entry.key, env_name: mapped_name, value: entry.value)
132
+ end
133
+
134
+ def self.safe_env_name?(name)
135
+ name.is_a?(String) && name.match?(ENV_NAME_PATTERN)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,67 @@
1
+ module LocalVault
2
+ class GroupCatalog
3
+ Entry = Data.define(:label, :value, :source_kind)
4
+ Group = Data.define(:name, :kind, :entries) do
5
+ def count
6
+ entries.length
7
+ end
8
+ end
9
+ Match = Data.define(:kind, :query, :groups) do
10
+ def group
11
+ groups.one? ? groups.first : nil
12
+ end
13
+ end
14
+
15
+ attr_reader :groups
16
+
17
+ def initialize(secrets)
18
+ grouped = {}
19
+
20
+ secrets.each do |name, value|
21
+ if value.is_a?(Hash)
22
+ append_namespace(grouped, name, value)
23
+ elsif name.include?("_")
24
+ append_entry(grouped, name.split("_", 2).first, Entry.new(name, value, :prefix))
25
+ end
26
+ end
27
+
28
+ @groups = grouped.map do |name, entries|
29
+ kinds = entries.map(&:source_kind).uniq
30
+ kind = kinds.length > 1 ? :mixed : kinds.first
31
+ Group.new(name, kind, entries.sort_by(&:label).freeze)
32
+ end.sort_by { |group| [group.name.downcase, group.name] }.freeze
33
+ end
34
+
35
+ def search(query = nil)
36
+ return groups if query.nil? || query.empty?
37
+
38
+ needle = query.downcase
39
+ groups.select { |group| group.name.downcase.start_with?(needle) }
40
+ end
41
+
42
+ def resolve(query)
43
+ exact = groups.select { |group| group.name == query }
44
+ return Match.new(:exact, query, exact) if exact.one?
45
+
46
+ insensitive = groups.select { |group| group.name.casecmp?(query) }
47
+ return Match.new(:unique, query, insensitive) if insensitive.one?
48
+ return Match.new(:ambiguous, query, insensitive) if insensitive.length > 1
49
+
50
+ matches = search(query)
51
+ kind = matches.one? ? :unique : (matches.empty? ? :absent : :ambiguous)
52
+ Match.new(kind, query, matches)
53
+ end
54
+
55
+ private
56
+
57
+ def append_namespace(grouped, name, values)
58
+ values.each do |label, value|
59
+ append_entry(grouped, name, Entry.new(label, value, :namespace))
60
+ end
61
+ end
62
+
63
+ def append_entry(grouped, name, entry)
64
+ (grouped[name] ||= []) << entry
65
+ end
66
+ end
67
+ end