legionio 1.4.62 → 1.4.63

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: 1c95080366554474379f7f8ad23dac75cc86125bfbd1081494f9c7a7d9bdde02
4
- data.tar.gz: 8e47342c47b50b7040456f83d46a0ba1fdd4bbe4d28315e3ea16acb5649c7a34
3
+ metadata.gz: 577e7caee9c14f38ce12e36d35f8a92fe4d66d33c7d62dbde89c8581575dc961
4
+ data.tar.gz: 0165ac8ccd3b32b48cce36a7188669ceb4254b344be99243a1e08fca77235a9f
5
5
  SHA512:
6
- metadata.gz: f29c940730a19e2119b343da7022511b08691d752af0663669880ad0b1cfcd8d3f5606abe14a215e7222d5cb4cc8bb7fd905c19b7035dfda275beae38bc55aa1
7
- data.tar.gz: 064c2b10a5d6c94b54e70fcb54275bf49026aea5b678087dc387d54613703c3cd473d51ce62671d1340d7c41b9e471eaee49a317965693eb5c1b47d086237c64
6
+ metadata.gz: 8192110eaf2b6f11cc38852448feade2999655706c657f2ed0ff78cd8fe698994c78443f90444d4ea0510060d66d6f679ebd6f58a9f9b46d42ef66352413434f
7
+ data.tar.gz: e807e335d274d228fac57bd6761380af2508f5c98cb40b8d4285ae9da9740cd10b1b592b3d3cad21e6a961f9f8cda05196fb5a35dee0a0ae18d6a6b969ddeb7f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.63] - 2026-03-18
4
+
5
+ ### Added
6
+ - `legionio config import SOURCE` command for importing config from URL or local file
7
+ - Supports raw JSON and base64-encoded JSON payloads
8
+ - Deep merges with existing `~/.legionio/settings/imported.json` (or `--force` to overwrite)
9
+ - Displays imported sections and vault cluster count
10
+
3
11
  ## [1.4.62] - 2026-03-18
4
12
 
5
13
  ### Added
@@ -0,0 +1,272 @@
1
+ # Config Import + Multi-Cluster Vault Design
2
+
3
+ ## Problem
4
+
5
+ LegionIO currently supports a single Vault cluster (`crypt.vault.address/port/token`). In enterprise environments, engineers work with multiple Vault clusters (dev, test, stage, production) and need different tokens for each. There's also no way to bootstrap a new developer's environment from a shared config — they must manually create JSON files in `~/.legionio/settings/`.
6
+
7
+ ## Solution
8
+
9
+ Three changes across three repos:
10
+
11
+ ### 1. legion-crypt: Multi-Cluster Vault Support
12
+
13
+ Upgrade `crypt.vault` from a single cluster to a named clusters hash with a `default` pointer.
14
+
15
+ #### Settings Schema
16
+
17
+ ```json
18
+ {
19
+ "crypt": {
20
+ "vault": {
21
+ "default": "prod",
22
+ "clusters": {
23
+ "dev": {
24
+ "address": "vault-dev.example.com",
25
+ "port": 8200,
26
+ "protocol": "https",
27
+ "namespace": "myapp",
28
+ "token": null,
29
+ "auth_method": "ldap"
30
+ },
31
+ "stage": {
32
+ "address": "vault-stage.example.com",
33
+ "port": 8200,
34
+ "protocol": "https",
35
+ "namespace": "myapp",
36
+ "token": null,
37
+ "auth_method": "ldap"
38
+ },
39
+ "prod": {
40
+ "address": "vault.example.com",
41
+ "port": 8200,
42
+ "protocol": "https",
43
+ "namespace": "myapp",
44
+ "token": null,
45
+ "auth_method": "ldap"
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ #### Backward Compatibility
54
+
55
+ If `crypt.vault.clusters` is absent but `crypt.vault.address` is present, treat it as a single unnamed cluster (current behavior). The migration path is:
56
+
57
+ ```ruby
58
+ # Old style (still works)
59
+ Legion::Settings[:crypt][:vault][:address] # => "vault.example.com"
60
+
61
+ # New style
62
+ Legion::Crypt.cluster(:prod) # => cluster config hash
63
+ Legion::Crypt.cluster # => default cluster config hash
64
+ Legion::Crypt.default_cluster # => "prod"
65
+ ```
66
+
67
+ #### New Module: `Legion::Crypt::VaultCluster`
68
+
69
+ Manages per-cluster Vault connections:
70
+
71
+ ```ruby
72
+ module Legion::Crypt
73
+ module VaultCluster
74
+ # Get a configured ::Vault client for a named cluster
75
+ def vault_client(name = nil)
76
+ name ||= default_cluster_name
77
+ @vault_clients ||= {}
78
+ @vault_clients[name] ||= build_client(clusters[name])
79
+ end
80
+
81
+ # Cluster config hash
82
+ def cluster(name = nil)
83
+ name ||= default_cluster_name
84
+ clusters[name]
85
+ end
86
+
87
+ def default_cluster_name
88
+ vault_settings[:default] || clusters.keys.first
89
+ end
90
+
91
+ def clusters
92
+ vault_settings[:clusters] || {}
93
+ end
94
+
95
+ # Connect to all clusters that have tokens
96
+ def connect_all
97
+ clusters.each do |name, config|
98
+ next unless config[:token]
99
+ connect_cluster(name)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def build_client(config)
106
+ client = ::Vault::Client.new(
107
+ address: "#{config[:protocol]}://#{config[:address]}:#{config[:port]}",
108
+ token: config[:token]
109
+ )
110
+ client.namespace = config[:namespace] if config[:namespace]
111
+ client
112
+ end
113
+ end
114
+ end
115
+ ```
116
+
117
+ #### New Module: `Legion::Crypt::LdapAuth`
118
+
119
+ LDAP authentication against Vault's LDAP auth method (HTTP API, no vault CLI):
120
+
121
+ ```ruby
122
+ module Legion::Crypt
123
+ module LdapAuth
124
+ # Authenticate to a single cluster via LDAP
125
+ # POST /v1/auth/ldap/login/:username
126
+ # Returns: { token:, lease_duration:, renewable:, policies: }
127
+ def ldap_login(cluster_name:, username:, password:)
128
+ client = vault_client(cluster_name)
129
+ # Or raw HTTP if ::Vault gem doesn't expose ldap auth:
130
+ response = client.post("/v1/auth/ldap/login/#{username}", password: password)
131
+ token = response.auth.client_token
132
+ # Store token in cluster config (in-memory only, not written to disk with password)
133
+ clusters[cluster_name][:token] = token
134
+ clusters[cluster_name][:connected] = true
135
+ { token: token, lease_duration: response.auth.lease_duration,
136
+ renewable: response.auth.renewable, policies: response.auth.policies }
137
+ end
138
+
139
+ # Authenticate to ALL configured clusters with same credentials
140
+ def ldap_login_all(username:, password:)
141
+ results = {}
142
+ clusters.each do |name, config|
143
+ next unless config[:auth_method] == 'ldap'
144
+ results[name] = ldap_login(cluster_name: name, username: username, password: password)
145
+ rescue StandardError => e
146
+ results[name] = { error: e.message }
147
+ end
148
+ results
149
+ end
150
+ end
151
+ end
152
+ ```
153
+
154
+ #### Existing Code Changes
155
+
156
+ - `Legion::Crypt.start` — if `clusters` present, call `connect_all` instead of `connect_vault`
157
+ - `Legion::Crypt::Vault.read/write/get` — route through `vault_client(name)` for cluster-aware reads
158
+ - `Legion::Crypt::Vault.connect_vault` — still works for legacy single-cluster config
159
+ - `Legion::Crypt::VaultRenewer` — renew tokens for ALL connected clusters
160
+ - `Legion::Settings::Resolver` — `vault://` refs gain optional cluster prefix: `vault://prod/secret/data/myapp#password` (falls back to default cluster if no prefix)
161
+
162
+ ### 2. LegionIO: `legion config import` / `legionio config import` CLI Command
163
+
164
+ New subcommand under `Config`:
165
+
166
+ ```
167
+ legionio config import <source> # URL or local file path
168
+ legion config import <source> # same command available in interactive binary
169
+ ```
170
+
171
+ #### Behavior
172
+
173
+ 1. **Fetch source:**
174
+ - If `source` starts with `http://` or `https://` — HTTP GET, follow redirects
175
+ - Otherwise — read local file
176
+ 2. **Decode payload:**
177
+ - Try `JSON.parse(body)` first
178
+ - If that fails, try `JSON.parse(Base64.decode64(body))`
179
+ - If both fail, error with "not valid JSON or base64-encoded JSON"
180
+ 3. **Validate structure:**
181
+ - Must be a Hash
182
+ - Warn on unrecognized top-level keys (not in known settings keys)
183
+ 4. **Write to `~/.legionio/settings/imported.json`:**
184
+ - Deep merge with existing imported.json if present
185
+ - Or overwrite with `--force`
186
+ 5. **Display summary:**
187
+ - Which settings sections were imported (crypt, transport, cache, etc.)
188
+ - How many vault clusters configured
189
+ - Remind user to run `legion` for onboarding vault auth
190
+
191
+ #### Example Config File
192
+
193
+ ```json
194
+ {
195
+ "crypt": {
196
+ "vault": {
197
+ "default": "prod",
198
+ "clusters": {
199
+ "dev": { "address": "vault-dev.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" },
200
+ "test": { "address": "vault-test.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" },
201
+ "stage": { "address": "vault-stage.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" },
202
+ "prod": { "address": "vault.uhg.com", "port": 8200, "protocol": "https", "auth_method": "ldap" }
203
+ }
204
+ }
205
+ },
206
+ "transport": {
207
+ "host": "rabbitmq.uhg.com",
208
+ "port": 5672,
209
+ "vhost": "legion"
210
+ },
211
+ "cache": {
212
+ "driver": "dalli",
213
+ "servers": ["memcached.uhg.com:11211"]
214
+ }
215
+ }
216
+ ```
217
+
218
+ ### 3. legion-tty: Onboarding Vault Auth Step
219
+
220
+ After the wizard (name + LLM providers), before the reveal box:
221
+
222
+ ```
223
+ [digital rain]
224
+ [intro - kerberos identity, github quick]
225
+ [wizard - name, LLM providers]
226
+ [NEW: vault auth prompt]
227
+ [reveal box - now includes vault cluster status]
228
+ ```
229
+
230
+ #### Flow
231
+
232
+ 1. Check if any vault clusters are configured in settings
233
+ 2. If none, skip entirely
234
+ 3. If clusters exist, ask: "I found N Vault clusters. Connect now?" (TTY::Prompt confirm)
235
+ 4. If yes:
236
+ - Default username = kerberos `samaccountname` (from `@kerberos_identity[:samaccountname]`), fallback to `ENV['USER']`
237
+ - Ask: "Username:" with default pre-filled (TTY::Prompt ask)
238
+ - Ask: "Password:" with `echo: false` (hidden input)
239
+ - For each LDAP-configured cluster, attempt `Legion::Crypt.ldap_login`
240
+ - Show green checkmark / red X per cluster with name
241
+ 5. Store tokens in memory (settings hash), NOT on disk with the password
242
+ 6. Reveal box now shows vault cluster connection status
243
+
244
+ #### New Background Probe: Not Needed
245
+
246
+ Vault auth requires user interaction (password prompt), so it runs inline after the wizard, not in a background thread.
247
+
248
+ ## Alternatives Considered
249
+
250
+ **Use lex-vault instead of vault gem for multi-cluster:** lex-vault's Faraday-based client is simpler and already supports per-instance address/token/namespace. Could replace the `vault` gem dependency in legion-crypt entirely. Deferred — not a requirement for this iteration but a good future optimization.
251
+
252
+ **Kerberos auth for Vault:** Not a default Vault auth method. Would require a custom Vault plugin. Deferred.
253
+
254
+ **Store tokens on disk:** Vault tokens are renewable and short-lived. Storing them risks stale tokens. Better to re-auth on each `legion` startup if needed. Could add optional token caching later.
255
+
256
+ ## Constraints
257
+
258
+ - LDAP password is NEVER written to disk or settings files
259
+ - Vault tokens are stored in-memory only during the session
260
+ - `vault://` resolver must remain backward compatible (no cluster prefix = default cluster)
261
+ - Single-cluster config (`crypt.vault.address`) must continue to work unchanged
262
+ - Config import file is plain JSON, no wrapper format
263
+ - HTTP sources must handle both raw JSON and base64-encoded JSON
264
+
265
+ ## Repos Affected
266
+
267
+ | Repo | Changes |
268
+ |------|---------|
269
+ | `legion-crypt` | `VaultCluster` module, `LdapAuth` module, multi-cluster settings, `VaultRenewer` update, backward compat |
270
+ | `LegionIO` | `config import` CLI command (both binaries), HTTP fetch + base64 detection |
271
+ | `legion-tty` | Onboarding vault auth step after wizard |
272
+ | `legion-settings` | `Resolver` update for cluster-prefixed `vault://` refs (optional, can defer) |
@@ -0,0 +1,833 @@
1
+ # Config Import + Multi-Cluster Vault Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add multi-cluster Vault support to legion-crypt, a `config import` CLI command, and onboarding Vault LDAP auth in legion-tty.
6
+
7
+ **Architecture:** Three repos changed independently. legion-crypt lands first (prerequisite), then LegionIO CLI and legion-tty can be done in parallel.
8
+
9
+ **Tech Stack:** Ruby, vault gem, Faraday (for LDAP HTTP auth), TTY::Prompt (hidden password input)
10
+
11
+ **Design Doc:** `docs/plans/2026-03-18-config-import-vault-multicluster-design.md`
12
+
13
+ ---
14
+
15
+ ## Phase 1: legion-crypt Multi-Cluster Vault (prerequisite)
16
+
17
+ ### Task 1: Multi-Cluster Settings Schema
18
+
19
+ **Files:**
20
+ - Modify: `legion-crypt/lib/legion/crypt/settings.rb`
21
+ - Test: `legion-crypt/spec/legion/settings_spec.rb`
22
+
23
+ **Step 1: Write the failing test**
24
+
25
+ ```ruby
26
+ # spec/legion/settings_spec.rb
27
+ describe 'vault defaults' do
28
+ it 'includes clusters hash' do
29
+ expect(vault[:clusters]).to eq({})
30
+ end
31
+
32
+ it 'includes default key' do
33
+ expect(vault[:default]).to be_nil
34
+ end
35
+ end
36
+ ```
37
+
38
+ **Step 2: Run test to verify it fails**
39
+
40
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/settings_spec.rb -v`
41
+ Expected: FAIL — no `:clusters` or `:default` key in vault defaults
42
+
43
+ **Step 3: Write minimal implementation**
44
+
45
+ Add to `Legion::Crypt::Settings.vault`:
46
+ ```ruby
47
+ def self.vault
48
+ {
49
+ enabled: !Gem::Specification.find_by_name('vault').nil?,
50
+ protocol: 'http',
51
+ address: 'localhost',
52
+ port: 8200,
53
+ token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil,
54
+ connected: false,
55
+ renewer_time: 5,
56
+ renewer: true,
57
+ push_cluster_secret: true,
58
+ read_cluster_secret: true,
59
+ kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion',
60
+ leases: {},
61
+ default: nil,
62
+ clusters: {}
63
+ }
64
+ end
65
+ ```
66
+
67
+ **Step 4: Run test to verify it passes**
68
+
69
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/settings_spec.rb -v`
70
+ Expected: PASS
71
+
72
+ **Step 5: Commit**
73
+
74
+ ```bash
75
+ git add lib/legion/crypt/settings.rb spec/legion/settings_spec.rb
76
+ git commit -m "add clusters and default keys to vault settings schema"
77
+ ```
78
+
79
+ ### Task 2: VaultCluster Module
80
+
81
+ **Files:**
82
+ - Create: `legion-crypt/lib/legion/crypt/vault_cluster.rb`
83
+ - Test: `legion-crypt/spec/legion/vault_cluster_spec.rb`
84
+
85
+ **Step 1: Write the failing test**
86
+
87
+ ```ruby
88
+ # spec/legion/vault_cluster_spec.rb
89
+ require 'spec_helper'
90
+ require 'legion/crypt/vault_cluster'
91
+
92
+ RSpec.describe Legion::Crypt::VaultCluster do
93
+ let(:test_obj) { Object.new.extend(described_class) }
94
+
95
+ before do
96
+ allow(test_obj).to receive(:vault_settings).and_return({
97
+ default: 'prod',
98
+ clusters: {
99
+ dev: { address: 'vault-dev.example.com', port: 8200, protocol: 'https', token: nil },
100
+ prod: { address: 'vault.example.com', port: 8200, protocol: 'https', token: 'hvs.abc123' }
101
+ }
102
+ })
103
+ end
104
+
105
+ describe '#default_cluster_name' do
106
+ it 'returns the configured default' do
107
+ expect(test_obj.default_cluster_name).to eq(:prod)
108
+ end
109
+ end
110
+
111
+ describe '#cluster' do
112
+ it 'returns default cluster when no name given' do
113
+ expect(test_obj.cluster[:address]).to eq('vault.example.com')
114
+ end
115
+
116
+ it 'returns named cluster' do
117
+ expect(test_obj.cluster(:dev)[:address]).to eq('vault-dev.example.com')
118
+ end
119
+
120
+ it 'returns nil for unknown cluster' do
121
+ expect(test_obj.cluster(:unknown)).to be_nil
122
+ end
123
+ end
124
+
125
+ describe '#clusters' do
126
+ it 'returns all clusters' do
127
+ expect(test_obj.clusters.keys).to contain_exactly(:dev, :prod)
128
+ end
129
+ end
130
+
131
+ describe '#vault_client' do
132
+ it 'returns a Vault::Client for the default cluster' do
133
+ client = test_obj.vault_client
134
+ expect(client).to be_a(::Vault::Client)
135
+ expect(client.address).to eq('https://vault.example.com:8200')
136
+ expect(client.token).to eq('hvs.abc123')
137
+ end
138
+
139
+ it 'returns a Vault::Client for a named cluster' do
140
+ client = test_obj.vault_client(:dev)
141
+ expect(client.address).to eq('https://vault-dev.example.com:8200')
142
+ end
143
+
144
+ it 'memoizes clients per cluster name' do
145
+ client1 = test_obj.vault_client(:prod)
146
+ client2 = test_obj.vault_client(:prod)
147
+ expect(client1).to equal(client2)
148
+ end
149
+ end
150
+
151
+ describe '#connected_clusters' do
152
+ it 'returns clusters that have tokens' do
153
+ expect(test_obj.connected_clusters.keys).to eq([:prod])
154
+ end
155
+ end
156
+ end
157
+ ```
158
+
159
+ **Step 2: Run test to verify it fails**
160
+
161
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/vault_cluster_spec.rb -v`
162
+ Expected: FAIL — `Legion::Crypt::VaultCluster` not defined
163
+
164
+ **Step 3: Write minimal implementation**
165
+
166
+ ```ruby
167
+ # lib/legion/crypt/vault_cluster.rb
168
+ # frozen_string_literal: true
169
+
170
+ require 'vault'
171
+
172
+ module Legion
173
+ module Crypt
174
+ module VaultCluster
175
+ def vault_client(name = nil)
176
+ name = (name || default_cluster_name).to_sym
177
+ @vault_clients ||= {}
178
+ @vault_clients[name] ||= build_vault_client(clusters[name])
179
+ end
180
+
181
+ def cluster(name = nil)
182
+ name = (name || default_cluster_name).to_sym
183
+ clusters[name]
184
+ end
185
+
186
+ def default_cluster_name
187
+ (vault_settings[:default] || clusters.keys.first).to_sym
188
+ end
189
+
190
+ def clusters
191
+ vault_settings[:clusters] || {}
192
+ end
193
+
194
+ def connected_clusters
195
+ clusters.select { |_, config| config[:token] }
196
+ end
197
+
198
+ def connect_all_clusters
199
+ results = {}
200
+ clusters.each do |name, config|
201
+ next unless config[:token]
202
+
203
+ client = vault_client(name)
204
+ config[:connected] = client.sys.health_status.initialized?
205
+ results[name] = config[:connected]
206
+ rescue StandardError => e
207
+ config[:connected] = false
208
+ results[name] = false
209
+ log_vault_error(name, e)
210
+ end
211
+ results
212
+ end
213
+
214
+ private
215
+
216
+ def build_vault_client(config)
217
+ return nil unless config.is_a?(Hash)
218
+
219
+ client = ::Vault::Client.new(
220
+ address: "#{config[:protocol]}://#{config[:address]}:#{config[:port]}",
221
+ token: config[:token]
222
+ )
223
+ client.namespace = config[:namespace] if config[:namespace]
224
+ client
225
+ end
226
+
227
+ def log_vault_error(name, error)
228
+ if defined?(Legion::Logging)
229
+ Legion::Logging.error("Vault cluster #{name}: #{error.message}")
230
+ else
231
+ warn("Vault cluster #{name}: #{error.message}")
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ ```
238
+
239
+ **Step 4: Run test to verify it passes**
240
+
241
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/vault_cluster_spec.rb -v`
242
+ Expected: PASS
243
+
244
+ **Step 5: Commit**
245
+
246
+ ```bash
247
+ git add lib/legion/crypt/vault_cluster.rb spec/legion/vault_cluster_spec.rb
248
+ git commit -m "add VaultCluster module for multi-cluster vault connections"
249
+ ```
250
+
251
+ ### Task 3: LdapAuth Module
252
+
253
+ **Files:**
254
+ - Create: `legion-crypt/lib/legion/crypt/ldap_auth.rb`
255
+ - Test: `legion-crypt/spec/legion/ldap_auth_spec.rb`
256
+
257
+ **Step 1: Write the failing test**
258
+
259
+ ```ruby
260
+ # spec/legion/ldap_auth_spec.rb
261
+ require 'spec_helper'
262
+ require 'legion/crypt/vault_cluster'
263
+ require 'legion/crypt/ldap_auth'
264
+
265
+ RSpec.describe Legion::Crypt::LdapAuth do
266
+ let(:test_obj) do
267
+ obj = Object.new
268
+ obj.extend(Legion::Crypt::VaultCluster)
269
+ obj.extend(described_class)
270
+ obj
271
+ end
272
+
273
+ let(:clusters_config) do
274
+ {
275
+ default: 'prod',
276
+ clusters: {
277
+ prod: { address: 'vault.example.com', port: 8200, protocol: 'https', auth_method: 'ldap', token: nil },
278
+ stage: { address: 'vault-stage.example.com', port: 8200, protocol: 'https', auth_method: 'ldap', token: nil },
279
+ dev: { address: 'vault-dev.example.com', port: 8200, protocol: 'https', auth_method: 'token', token: 'hvs.existing' }
280
+ }
281
+ }
282
+ end
283
+
284
+ before do
285
+ allow(test_obj).to receive(:vault_settings).and_return(clusters_config)
286
+ end
287
+
288
+ describe '#ldap_login' do
289
+ it 'authenticates to a cluster and stores the token' do
290
+ mock_auth = double(client_token: 'hvs.newtoken', lease_duration: 3600, renewable: true, policies: ['default'])
291
+ mock_secret = double(auth: mock_auth)
292
+ mock_logical = double(write: mock_secret)
293
+ mock_client = instance_double(::Vault::Client, logical: mock_logical)
294
+ allow(test_obj).to receive(:vault_client).with(:prod).and_return(mock_client)
295
+
296
+ result = test_obj.ldap_login(cluster_name: :prod, username: 'jdoe', password: 's3cret')
297
+ expect(result[:token]).to eq('hvs.newtoken')
298
+ expect(result[:lease_duration]).to eq(3600)
299
+ expect(clusters_config[:clusters][:prod][:token]).to eq('hvs.newtoken')
300
+ end
301
+ end
302
+
303
+ describe '#ldap_login_all' do
304
+ it 'authenticates to all LDAP clusters and skips non-LDAP ones' do
305
+ mock_auth = double(client_token: 'hvs.tok', lease_duration: 3600, renewable: true, policies: ['default'])
306
+ mock_secret = double(auth: mock_auth)
307
+ mock_logical = double(write: mock_secret)
308
+ mock_client = instance_double(::Vault::Client, logical: mock_logical)
309
+ allow(test_obj).to receive(:vault_client).and_return(mock_client)
310
+
311
+ results = test_obj.ldap_login_all(username: 'jdoe', password: 's3cret')
312
+ expect(results.keys).to contain_exactly(:prod, :stage)
313
+ expect(results[:prod][:token]).to eq('hvs.tok')
314
+ expect(results[:stage][:token]).to eq('hvs.tok')
315
+ end
316
+
317
+ it 'captures errors per cluster without stopping' do
318
+ allow(test_obj).to receive(:vault_client).and_raise(StandardError.new('connection refused'))
319
+
320
+ results = test_obj.ldap_login_all(username: 'jdoe', password: 's3cret')
321
+ expect(results[:prod][:error]).to eq('connection refused')
322
+ expect(results[:stage][:error]).to eq('connection refused')
323
+ end
324
+ end
325
+ end
326
+ ```
327
+
328
+ **Step 2: Run test to verify it fails**
329
+
330
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/ldap_auth_spec.rb -v`
331
+ Expected: FAIL — `Legion::Crypt::LdapAuth` not defined
332
+
333
+ **Step 3: Write minimal implementation**
334
+
335
+ ```ruby
336
+ # lib/legion/crypt/ldap_auth.rb
337
+ # frozen_string_literal: true
338
+
339
+ module Legion
340
+ module Crypt
341
+ module LdapAuth
342
+ def ldap_login(cluster_name:, username:, password:)
343
+ client = vault_client(cluster_name)
344
+ secret = client.logical.write("auth/ldap/login/#{username}", password: password)
345
+ auth = secret.auth
346
+ token = auth.client_token
347
+
348
+ clusters[cluster_name][:token] = token
349
+ clusters[cluster_name][:connected] = true
350
+
351
+ { token: token, lease_duration: auth.lease_duration,
352
+ renewable: auth.renewable, policies: auth.policies }
353
+ end
354
+
355
+ def ldap_login_all(username:, password:)
356
+ results = {}
357
+ clusters.each do |name, config|
358
+ next unless config[:auth_method] == 'ldap'
359
+
360
+ results[name] = ldap_login(cluster_name: name, username: username, password: password)
361
+ rescue StandardError => e
362
+ results[name] = { error: e.message }
363
+ end
364
+ results
365
+ end
366
+ end
367
+ end
368
+ end
369
+ ```
370
+
371
+ **Step 4: Run test to verify it passes**
372
+
373
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/ldap_auth_spec.rb -v`
374
+ Expected: PASS
375
+
376
+ **Step 5: Commit**
377
+
378
+ ```bash
379
+ git add lib/legion/crypt/ldap_auth.rb spec/legion/ldap_auth_spec.rb
380
+ git commit -m "add LdapAuth module for vault LDAP authentication"
381
+ ```
382
+
383
+ ### Task 4: Wire Multi-Cluster into Legion::Crypt.start
384
+
385
+ **Files:**
386
+ - Modify: `legion-crypt/lib/legion/crypt.rb`
387
+ - Modify: `legion-crypt/lib/legion/crypt/vault.rb`
388
+ - Test: `legion-crypt/spec/legion/crypt_spec.rb`
389
+
390
+ **Step 1: Write the failing test**
391
+
392
+ ```ruby
393
+ # Add to spec/legion/crypt_spec.rb
394
+ describe '.cluster' do
395
+ it 'delegates to VaultCluster#cluster' do
396
+ expect(Legion::Crypt).to respond_to(:cluster)
397
+ end
398
+ end
399
+
400
+ describe '.ldap_login_all' do
401
+ it 'delegates to LdapAuth#ldap_login_all' do
402
+ expect(Legion::Crypt).to respond_to(:ldap_login_all)
403
+ end
404
+ end
405
+ ```
406
+
407
+ **Step 2: Run test to verify it fails**
408
+
409
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/crypt_spec.rb -v`
410
+ Expected: FAIL — `Legion::Crypt.cluster` not defined
411
+
412
+ **Step 3: Write minimal implementation**
413
+
414
+ In `lib/legion/crypt.rb`, add:
415
+ ```ruby
416
+ require_relative 'crypt/vault_cluster'
417
+ require_relative 'crypt/ldap_auth'
418
+
419
+ module Legion
420
+ module Crypt
421
+ extend VaultCluster
422
+ extend LdapAuth
423
+
424
+ def self.vault_settings
425
+ Legion::Settings[:crypt][:vault]
426
+ end
427
+
428
+ # Update start to handle multi-cluster
429
+ def self.start
430
+ # ... existing code ...
431
+ if vault_settings[:clusters]&.any?
432
+ connect_all_clusters
433
+ else
434
+ connect_vault # legacy single-cluster path
435
+ end
436
+ end
437
+ end
438
+ end
439
+ ```
440
+
441
+ **Step 4: Run test to verify it passes**
442
+
443
+ Run: `cd legion-crypt && bundle exec rspec spec/legion/crypt_spec.rb -v`
444
+ Expected: PASS
445
+
446
+ **Step 5: Run full suite and commit**
447
+
448
+ ```bash
449
+ cd legion-crypt && bundle exec rspec && bundle exec rubocop -A && bundle exec rubocop
450
+ git add lib/legion/crypt.rb lib/legion/crypt/vault.rb spec/legion/crypt_spec.rb
451
+ git commit -m "wire multi-cluster vault into Legion::Crypt.start with backward compat"
452
+ ```
453
+
454
+ ### Task 5: Update VaultRenewer for Multi-Cluster
455
+
456
+ **Files:**
457
+ - Modify: `legion-crypt/lib/legion/crypt/vault_renewer.rb`
458
+ - Test: `legion-crypt/spec/legion/vault_renewer_spec.rb`
459
+
460
+ Renewer must iterate `connected_clusters` and renew each token. If no clusters are configured, fall back to single-cluster renewal (existing behavior).
461
+
462
+ ### Task 6: Version Bump + Pipeline
463
+
464
+ **Files:**
465
+ - Modify: `legion-crypt/lib/legion/crypt/version.rb` (bump to 1.4.4)
466
+ - Modify: `legion-crypt/CHANGELOG.md`
467
+
468
+ Run full pre-push pipeline: rspec, rubocop -A, rubocop, version bump, changelog, push.
469
+
470
+ ---
471
+
472
+ ## Phase 2: LegionIO `config import` CLI Command
473
+
474
+ ### Task 7: Config Import Command
475
+
476
+ **Files:**
477
+ - Create: `LegionIO/lib/legion/cli/config_import.rb`
478
+ - Modify: `LegionIO/lib/legion/cli/config_command.rb` (register subcommand)
479
+ - Test: `LegionIO/spec/legion/cli/config_import_spec.rb`
480
+
481
+ **Step 1: Write the failing test**
482
+
483
+ ```ruby
484
+ # spec/legion/cli/config_import_spec.rb
485
+ require 'spec_helper'
486
+ require 'legion/cli/config_import'
487
+
488
+ RSpec.describe Legion::CLI::ConfigImport do
489
+ describe '.parse_payload' do
490
+ it 'parses raw JSON' do
491
+ result = described_class.parse_payload('{"crypt": {"vault": {}}}')
492
+ expect(result).to eq({ crypt: { vault: {} } })
493
+ end
494
+
495
+ it 'parses base64-encoded JSON' do
496
+ encoded = Base64.strict_encode64('{"transport": {"host": "rmq.example.com"}}')
497
+ result = described_class.parse_payload(encoded)
498
+ expect(result[:transport][:host]).to eq('rmq.example.com')
499
+ end
500
+
501
+ it 'raises on invalid input' do
502
+ expect { described_class.parse_payload('not json at all %%%') }.to raise_error(Legion::CLI::Error)
503
+ end
504
+ end
505
+
506
+ describe '.fetch_source' do
507
+ it 'reads a local file' do
508
+ tmpfile = Tempfile.new(['config', '.json'])
509
+ tmpfile.write('{"cache": {"driver": "dalli"}}')
510
+ tmpfile.close
511
+ result = described_class.fetch_source(tmpfile.path)
512
+ expect(result).to include('"cache"')
513
+ tmpfile.unlink
514
+ end
515
+ end
516
+ end
517
+ ```
518
+
519
+ **Step 2: Run test to verify it fails**
520
+
521
+ Run: `cd LegionIO && bundle exec rspec spec/legion/cli/config_import_spec.rb -v`
522
+ Expected: FAIL — file doesn't exist
523
+
524
+ **Step 3: Write minimal implementation**
525
+
526
+ ```ruby
527
+ # lib/legion/cli/config_import.rb
528
+ # frozen_string_literal: true
529
+
530
+ require 'base64'
531
+ require 'net/http'
532
+ require 'uri'
533
+ require 'fileutils'
534
+
535
+ module Legion
536
+ module CLI
537
+ class ConfigImport
538
+ SETTINGS_DIR = File.expand_path('~/.legionio/settings')
539
+ IMPORT_FILE = 'imported.json'
540
+
541
+ def self.fetch_source(source)
542
+ if source.match?(%r{\Ahttps?://})
543
+ fetch_http(source)
544
+ else
545
+ raise CLI::Error, "File not found: #{source}" unless File.exist?(source)
546
+
547
+ File.read(source)
548
+ end
549
+ end
550
+
551
+ def self.fetch_http(url)
552
+ uri = URI.parse(url)
553
+ response = Net::HTTP.get_response(uri)
554
+ raise CLI::Error, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
555
+
556
+ response.body
557
+ end
558
+
559
+ def self.parse_payload(body)
560
+ # Try raw JSON first
561
+ parsed = ::JSON.parse(body, symbolize_names: true)
562
+ raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
563
+
564
+ parsed
565
+ rescue ::JSON::ParserError
566
+ # Try base64-decoded JSON
567
+ begin
568
+ decoded = Base64.decode64(body)
569
+ parsed = ::JSON.parse(decoded, symbolize_names: true)
570
+ raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
571
+
572
+ parsed
573
+ rescue ::JSON::ParserError
574
+ raise CLI::Error, 'Source is not valid JSON or base64-encoded JSON'
575
+ end
576
+ end
577
+
578
+ def self.write_config(config, force: false)
579
+ FileUtils.mkdir_p(SETTINGS_DIR)
580
+ path = File.join(SETTINGS_DIR, IMPORT_FILE)
581
+
582
+ if File.exist?(path) && !force
583
+ existing = ::JSON.parse(File.read(path), symbolize_names: true)
584
+ config = deep_merge(existing, config)
585
+ end
586
+
587
+ File.write(path, ::JSON.pretty_generate(config))
588
+ path
589
+ end
590
+
591
+ def self.deep_merge(base, overlay)
592
+ base.merge(overlay) do |_key, old_val, new_val|
593
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
594
+ deep_merge(old_val, new_val)
595
+ else
596
+ new_val
597
+ end
598
+ end
599
+ end
600
+
601
+ def self.summary(config)
602
+ sections = config.keys.map(&:to_s)
603
+ vault_clusters = config.dig(:crypt, :vault, :clusters)&.keys&.map(&:to_s) || []
604
+ { sections: sections, vault_clusters: vault_clusters }
605
+ end
606
+ end
607
+ end
608
+ end
609
+ ```
610
+
611
+ **Step 4: Run test to verify it passes**
612
+
613
+ Run: `cd LegionIO && bundle exec rspec spec/legion/cli/config_import_spec.rb -v`
614
+ Expected: PASS
615
+
616
+ **Step 5: Commit**
617
+
618
+ ```bash
619
+ git add lib/legion/cli/config_import.rb spec/legion/cli/config_import_spec.rb
620
+ git commit -m "add config import utility for URL and local file sources"
621
+ ```
622
+
623
+ ### Task 8: Wire Import into Config Subcommand
624
+
625
+ **Files:**
626
+ - Modify: `LegionIO/lib/legion/cli/config_command.rb`
627
+
628
+ Add `import` subcommand to Config Thor class:
629
+ ```ruby
630
+ desc 'import SOURCE', 'Import configuration from a URL or local file'
631
+ option :force, type: :boolean, default: false, desc: 'Overwrite existing imported config'
632
+ def import(source)
633
+ out = formatter
634
+ require_relative 'config_import'
635
+
636
+ out.info("Fetching config from #{source}...")
637
+ body = ConfigImport.fetch_source(source)
638
+ config = ConfigImport.parse_payload(body)
639
+ path = ConfigImport.write_config(config, force: options[:force])
640
+ summary = ConfigImport.summary(config)
641
+
642
+ out.success("Config written to #{path}")
643
+ out.info("Sections: #{summary[:sections].join(', ')}")
644
+ if summary[:vault_clusters].any?
645
+ out.info("Vault clusters: #{summary[:vault_clusters].join(', ')}")
646
+ out.info("Run 'legion' to authenticate via LDAP during onboarding")
647
+ end
648
+ rescue CLI::Error => e
649
+ formatter.error(e.message)
650
+ raise SystemExit, 1
651
+ end
652
+ ```
653
+
654
+ **Step 1: Write test, Step 2: Verify fail, Step 3: Implement, Step 4: Verify pass**
655
+
656
+ **Step 5: Commit**
657
+
658
+ ```bash
659
+ git add lib/legion/cli/config_command.rb
660
+ git commit -m "add 'config import' subcommand for URL and local file config import"
661
+ ```
662
+
663
+ ### Task 9: Version Bump + Pipeline for LegionIO
664
+
665
+ Run full pre-push pipeline. Bump to 1.4.63.
666
+
667
+ ---
668
+
669
+ ## Phase 3: legion-tty Onboarding Vault Auth
670
+
671
+ ### Task 10: VaultAuth Background-Free Prompt
672
+
673
+ **Files:**
674
+ - Create: `legion-tty/lib/legion/tty/screens/vault_auth.rb` (extracted helper, not a full screen)
675
+ - Modify: `legion-tty/lib/legion/tty/screens/onboarding.rb`
676
+ - Test: `legion-tty/spec/legion/tty/screens/onboarding_spec.rb`
677
+
678
+ **Step 1: Write the failing test**
679
+
680
+ ```ruby
681
+ # Add to onboarding_spec.rb
682
+ describe '#run_vault_auth' do
683
+ context 'when no vault clusters configured' do
684
+ it 'skips vault auth entirely' do
685
+ allow(screen).to receive(:vault_clusters_configured?).and_return(false)
686
+ expect(wizard).not_to receive(:confirm)
687
+ screen.send(:run_vault_auth)
688
+ end
689
+ end
690
+
691
+ context 'when vault clusters configured' do
692
+ before do
693
+ allow(screen).to receive(:vault_clusters_configured?).and_return(true)
694
+ allow(screen).to receive(:vault_cluster_count).and_return(3)
695
+ end
696
+
697
+ it 'asks user if they want to connect' do
698
+ allow(wizard).to receive(:confirm).and_return(false)
699
+ screen.send(:run_vault_auth)
700
+ expect(wizard).to have_received(:confirm).with(/3 Vault clusters/)
701
+ end
702
+ end
703
+ end
704
+ ```
705
+
706
+ **Step 2: Run test to verify it fails**
707
+
708
+ Run: `cd legion-tty && bundle exec rspec spec/legion/tty/screens/onboarding_spec.rb -v`
709
+ Expected: FAIL
710
+
711
+ **Step 3: Write minimal implementation**
712
+
713
+ Add to `onboarding.rb`:
714
+
715
+ ```ruby
716
+ def run_vault_auth
717
+ return unless vault_clusters_configured?
718
+
719
+ count = vault_cluster_count
720
+ typed_output("I found #{count} Vault cluster#{'s' if count != 1}.")
721
+ @output.puts
722
+ return unless @wizard.confirm("Connect now?")
723
+
724
+ username = default_vault_username
725
+ username = @wizard.ask_with_default('Username:', username)
726
+ password = @wizard.ask_secret('Password:')
727
+
728
+ typed_output('Authenticating...')
729
+ @output.puts
730
+
731
+ results = Legion::Crypt.ldap_login_all(username: username, password: password)
732
+ display_vault_results(results)
733
+ end
734
+
735
+ def vault_clusters_configured?
736
+ return false unless defined?(Legion::Crypt)
737
+
738
+ clusters = Legion::Settings.dig(:crypt, :vault, :clusters)
739
+ clusters.is_a?(Hash) && clusters.any?
740
+ rescue StandardError
741
+ false
742
+ end
743
+
744
+ def vault_cluster_count
745
+ Legion::Settings.dig(:crypt, :vault, :clusters)&.size || 0
746
+ end
747
+
748
+ def default_vault_username
749
+ if @kerberos_identity
750
+ @kerberos_identity[:samaccountname] || @kerberos_identity[:first_name]&.downcase
751
+ else
752
+ ENV.fetch('USER', 'unknown')
753
+ end
754
+ end
755
+
756
+ def display_vault_results(results)
757
+ results.each do |name, result|
758
+ if result[:error]
759
+ @output.puts " #{Theme.c(:error, 'X')} #{name}: #{result[:error]}"
760
+ else
761
+ @output.puts " #{Theme.c(:success, 'ok')} #{name}: connected (#{result[:policies]&.size || 0} policies)"
762
+ end
763
+ end
764
+ @output.puts
765
+ sleep 1
766
+ end
767
+ ```
768
+
769
+ Wire into `activate` method between `run_wizard` and `collect_background_results`:
770
+ ```ruby
771
+ def activate
772
+ start_background_threads
773
+ run_rain unless @skip_rain
774
+ run_intro
775
+ config = run_wizard
776
+ run_vault_auth # <-- NEW
777
+ scan_data, github_data = collect_background_results
778
+ # ...
779
+ end
780
+ ```
781
+
782
+ **Step 4: Run test to verify it passes**
783
+
784
+ Run: `cd legion-tty && bundle exec rspec spec/legion/tty/screens/onboarding_spec.rb -v`
785
+ Expected: PASS
786
+
787
+ **Step 5: Commit**
788
+
789
+ ```bash
790
+ git add lib/legion/tty/screens/onboarding.rb spec/legion/tty/screens/onboarding_spec.rb
791
+ git commit -m "add vault LDAP auth step to onboarding wizard"
792
+ ```
793
+
794
+ ### Task 11: WizardPrompt Secret Input
795
+
796
+ **Files:**
797
+ - Modify: `legion-tty/lib/legion/tty/components/wizard_prompt.rb`
798
+ - Test: `legion-tty/spec/legion/tty/components/wizard_prompt_spec.rb`
799
+
800
+ Add `ask_secret` and `ask_with_default` methods to WizardPrompt:
801
+
802
+ ```ruby
803
+ def ask_secret(question)
804
+ @prompt.mask(question)
805
+ end
806
+
807
+ def ask_with_default(question, default)
808
+ @prompt.ask(question, default: default)
809
+ end
810
+ ```
811
+
812
+ ### Task 12: Vault Summary in Reveal Box
813
+
814
+ **Files:**
815
+ - Modify: `legion-tty/lib/legion/tty/screens/onboarding.rb`
816
+
817
+ Add `vault_summary_lines` to `build_summary`, showing connected/disconnected vault clusters.
818
+
819
+ ### Task 13: Version Bump + Pipeline for legion-tty
820
+
821
+ Bump to 0.2.3. Run full pre-push pipeline.
822
+
823
+ ---
824
+
825
+ ## Execution Order
826
+
827
+ ```
828
+ Task 1-6 (legion-crypt) — FIRST, prerequisite
829
+ Task 7-9 (LegionIO) — after Task 6, can parallel with Tasks 10-13
830
+ Task 10-13 (legion-tty) — after Task 6, can parallel with Tasks 7-9
831
+ ```
832
+
833
+ ## Recommended Execution: `1 → 2 → 3 → 4 → 5 → 6 → [7-9 || 10-13]`
@@ -182,6 +182,29 @@ module Legion
182
182
  raise SystemExit, exit_code if exit_code != 0
183
183
  end
184
184
 
185
+ desc 'import SOURCE', 'Import configuration from a URL or local file'
186
+ option :force, type: :boolean, default: false, desc: 'Overwrite existing imported config'
187
+ def import(source)
188
+ out = formatter
189
+ require_relative 'config_import'
190
+
191
+ out.info("Fetching config from #{source}...")
192
+ body = ConfigImport.fetch_source(source)
193
+ config = ConfigImport.parse_payload(body)
194
+ path = ConfigImport.write_config(config, force: options[:force])
195
+ summary = ConfigImport.summary(config)
196
+
197
+ out.success("Config written to #{path}")
198
+ out.info("Sections: #{summary[:sections].join(', ')}")
199
+ if summary[:vault_clusters].any?
200
+ out.info("Vault clusters: #{summary[:vault_clusters].join(', ')}")
201
+ out.info("Run 'legion' to authenticate via LDAP during onboarding")
202
+ end
203
+ rescue CLI::Error => e
204
+ formatter.error(e.message)
205
+ raise SystemExit, 1
206
+ end
207
+
185
208
  no_commands do # rubocop:disable Metrics/BlockLength
186
209
  def formatter
187
210
  @formatter ||= Output::Formatter.new(
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'fileutils'
7
+ require 'json'
8
+
9
+ module Legion
10
+ module CLI
11
+ module ConfigImport
12
+ SETTINGS_DIR = File.expand_path('~/.legionio/settings')
13
+ IMPORT_FILE = 'imported.json'
14
+
15
+ module_function
16
+
17
+ def fetch_source(source)
18
+ if source.match?(%r{\Ahttps?://})
19
+ fetch_http(source)
20
+ else
21
+ raise CLI::Error, "File not found: #{source}" unless File.exist?(source)
22
+
23
+ File.read(source)
24
+ end
25
+ end
26
+
27
+ def fetch_http(url)
28
+ uri = URI.parse(url)
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == 'https'
31
+ http.open_timeout = 10
32
+ http.read_timeout = 10
33
+ request = Net::HTTP::Get.new(uri)
34
+ response = http.request(request)
35
+ raise CLI::Error, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
36
+
37
+ response.body
38
+ end
39
+
40
+ def parse_payload(body)
41
+ parsed = ::JSON.parse(body, symbolize_names: true)
42
+ raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
43
+
44
+ parsed
45
+ rescue ::JSON::ParserError
46
+ begin
47
+ decoded = Base64.decode64(body)
48
+ parsed = ::JSON.parse(decoded, symbolize_names: true)
49
+ raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash)
50
+
51
+ parsed
52
+ rescue ::JSON::ParserError
53
+ raise CLI::Error, 'Source is not valid JSON or base64-encoded JSON'
54
+ end
55
+ end
56
+
57
+ def write_config(config, force: false)
58
+ FileUtils.mkdir_p(SETTINGS_DIR)
59
+ path = File.join(SETTINGS_DIR, IMPORT_FILE)
60
+
61
+ if File.exist?(path) && !force
62
+ existing = ::JSON.parse(File.read(path), symbolize_names: true)
63
+ config = deep_merge(existing, config)
64
+ end
65
+
66
+ File.write(path, ::JSON.pretty_generate(config))
67
+ path
68
+ end
69
+
70
+ def deep_merge(base, overlay)
71
+ base.merge(overlay) do |_key, old_val, new_val|
72
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
73
+ deep_merge(old_val, new_val)
74
+ else
75
+ new_val
76
+ end
77
+ end
78
+ end
79
+
80
+ def summary(config)
81
+ sections = config.keys.map(&:to_s)
82
+ vault_clusters = config.dig(:crypt, :vault, :clusters)&.keys&.map(&:to_s) || []
83
+ { sections: sections, vault_clusters: vault_clusters }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.62'
4
+ VERSION = '1.4.63'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.62
4
+ version: 1.4.63
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -329,6 +329,8 @@ files:
329
329
  - completions/legionio.bash
330
330
  - docker_deploy.rb
331
331
  - docs/README.md
332
+ - docs/plans/2026-03-18-config-import-vault-multicluster-design.md
333
+ - docs/plans/2026-03-18-config-import-vault-multicluster-implementation.md
332
334
  - docs/plans/2026-03-18-legion-tty-default-cli-design.md
333
335
  - docs/plans/2026-03-18-legion-tty-default-cli-implementation.md
334
336
  - exe/legion
@@ -420,6 +422,7 @@ files:
420
422
  - lib/legion/cli/commit_command.rb
421
423
  - lib/legion/cli/completion_command.rb
422
424
  - lib/legion/cli/config_command.rb
425
+ - lib/legion/cli/config_import.rb
423
426
  - lib/legion/cli/config_scaffold.rb
424
427
  - lib/legion/cli/connection.rb
425
428
  - lib/legion/cli/cost/data_client.rb