localvault 1.6.0 → 1.6.2

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: 6c3cc5ee20ea5b0017e6ce6b7879234c36d2c30f6a507ca8dd119c9b24a32fa9
4
- data.tar.gz: 28c32bd08489c90748782c8320bca666e95a1cf71cf5deb22bcb443bf776e406
3
+ metadata.gz: 3fe9cdf4fe4857588507532f6478318a7b02cdc47487bbecbe7ce54715e69e33
4
+ data.tar.gz: 1be1af8d9476e1171895a0ba0677e206832de1386416cd5fb7e7060e7b8b85f5
5
5
  SHA512:
6
- metadata.gz: efd141b468fe4f8c0ec3b1b180ef492c3521f074574bbb600ec0d2cc871927df73d7db4c06093eb25444816ae0fa7b92452470f41b65ad7b959a1f6e8a1511b8
7
- data.tar.gz: c385bc4e21bec7ca233633e452496589a626f6706672270efc3365b4036f1da1c0274809a96d4d4880f6ba519bf319c7f860afe644f742d01d657e3b3d8327e1
6
+ metadata.gz: 500451ad18462b5380839eb06b031b83b3acb91872430855e07038896c392f9488934aaf1649bd311886890400aaef9c45a062d8503c6db0760a2927b56a1650
7
+ data.tar.gz: aa1aa1b764c2de50ab0c8fb425b14a77e05085f2224131c7f53dc1eaaccc9b80732f160716a1826ef479c0d9653e5451074ca585927a86630ef68a92f23b9b8c
data/README.md CHANGED
@@ -100,10 +100,11 @@ localvault exec -- rails server
100
100
  | `login [TOKEN]` | Log in to InventList — auto-generates X25519 keypair + publishes public key |
101
101
  | `login --status` | Show current login status |
102
102
  | `logout` | Clear stored credentials |
103
- | `sync push [NAME]` | Push encrypted vault to cloud |
104
- | `sync pull [NAME]` | Pull vault from cloud (auto-unlocks if you have a key slot) |
103
+ | `sync` | Sync all vaults bidirectionally (push local, pull remote, detect conflicts) |
104
+ | `sync --dry-run` | Preview what sync would do without making changes |
105
+ | `sync push [NAME]` | Push one vault to cloud |
106
+ | `sync pull [NAME]` | Pull one vault from cloud (auto-unlocks if you have a key slot) |
105
107
  | `sync status` | Show sync state for all vaults |
106
- | `config set server URL` | Point at a custom server (default: inventlist.com) |
107
108
 
108
109
  ### Team Sharing (v1.3.0)
109
110
 
@@ -137,7 +138,7 @@ backward compatibility but the top-level forms are preferred.
137
138
 
138
139
  | Command | Description |
139
140
  |---------|-------------|
140
- | `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, windsurf, or zed |
141
+ | `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, or windsurf |
141
142
  | `mcp` | Start MCP server (stdio transport) |
142
143
 
143
144
  All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
@@ -147,22 +148,32 @@ All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. De
147
148
  Sync your vaults between machines — same passphrase, no team features needed:
148
149
 
149
150
  ```bash
150
- # Machine A: push your vault
151
- localvault sync push
151
+ # Machine A: push all your vaults at once
152
+ localvault sync
152
153
 
153
- # Machine B: install, login, pull
154
+ # Machine B: install, login, sync
154
155
  brew install inventlist/tap/localvault
155
156
  localvault login YOUR_TOKEN
156
- localvault sync pull
157
- localvault show # enter your passphrase — same secrets
157
+ localvault sync # pulls everything, pushes local-only vaults
158
+ localvault show # enter your passphrase — same secrets
158
159
  ```
159
160
 
160
- Check what's synced:
161
+ Or push/pull individual vaults:
161
162
 
162
163
  ```bash
163
- localvault sync status
164
- # default synced 2 minutes ago
165
- # production local only
164
+ localvault sync push production # push one vault
165
+ localvault sync pull production # pull one vault
166
+ localvault sync status # check what's synced vs local-only
167
+ ```
168
+
169
+ Preview before syncing:
170
+
171
+ ```bash
172
+ localvault sync --dry-run
173
+ # Vault Action Reason
174
+ # default skip up to date
175
+ # production push local changes
176
+ # staging pull remote changes
166
177
  ```
167
178
 
168
179
  ## Team Sharing
@@ -17,6 +17,7 @@ module LocalVault
17
17
  # - Both exist, only local changed → push
18
18
  # - Both exist, only remote changed → pull
19
19
  # - Both exist, neither changed → skip
20
+ # - Both exist, no baseline but secrets identical → adopt (record baseline)
20
21
  # - Both exist, both changed → CONFLICT (manual resolution)
21
22
  # - Shared vault (not owned by you) → pull-only
22
23
  def all
@@ -34,7 +35,7 @@ module LocalVault
34
35
  return
35
36
  end
36
37
 
37
- plan = all_names.map { |name| classify_vault(name, local_set, remote_map, my_handle) }
38
+ plan = all_names.map { |name| classify_vault(name, local_set, remote_map, my_handle, client) }
38
39
 
39
40
  # Print plan
40
41
  max_name = (["Vault"] + plan.map { |p| p[:name] }).map(&:length).max
@@ -55,7 +56,7 @@ module LocalVault
55
56
  end
56
57
 
57
58
  # Execute
58
- pushed = pulled = skipped = conflicts = errors = 0
59
+ pushed = pulled = skipped = adopted = conflicts = errors = 0
59
60
  plan.each do |entry|
60
61
  case entry[:action]
61
62
  when :push
@@ -70,6 +71,12 @@ module LocalVault
70
71
  else
71
72
  errors += 1
72
73
  end
74
+ when :adopt
75
+ if perform_adopt(entry[:name])
76
+ adopted += 1
77
+ else
78
+ errors += 1
79
+ end
73
80
  when :skip
74
81
  skipped += 1
75
82
  when :conflict
@@ -79,10 +86,11 @@ module LocalVault
79
86
 
80
87
  # Summary
81
88
  parts = []
82
- parts << "#{pushed} pushed" if pushed > 0
83
- parts << "#{pulled} pulled" if pulled > 0
89
+ parts << "#{pushed} pushed" if pushed > 0
90
+ parts << "#{pulled} pulled" if pulled > 0
91
+ parts << "#{adopted} baselined" if adopted > 0
84
92
  parts << "#{skipped} up to date" if skipped > 0
85
- parts << "#{errors} failed" if errors > 0
93
+ parts << "#{errors} failed" if errors > 0
86
94
  parts << "#{conflicts} conflict#{conflicts == 1 ? "" : "s"}" if conflicts > 0
87
95
  $stdout.puts "Summary: #{parts.join(", ")}"
88
96
 
@@ -165,6 +173,15 @@ module LocalVault
165
173
 
166
174
  # ── Core push logic ──────────────────────────────────────────
167
175
 
176
+ # Push a single vault to the cloud. Detects team vs personal mode,
177
+ # checks push authorization, packs the SyncBundle, uploads, and
178
+ # records the checksum in +.sync_state+ on success.
179
+ #
180
+ # Used by both the public +push+ command and the +all+ sync loop.
181
+ #
182
+ # @param vault_name [String] vault to push
183
+ # @param client [ApiClient] authenticated API client
184
+ # @return [Boolean] true on success, false on any error
168
185
  def perform_push(vault_name, client)
169
186
  store = Store.new(vault_name)
170
187
  unless store.exists?
@@ -220,6 +237,14 @@ module LocalVault
220
237
 
221
238
  # ── Core pull logic ──────────────────────────────────────────
222
239
 
240
+ # Pull a single vault from the cloud. Downloads the SyncBundle,
241
+ # writes meta.yml and secrets.enc locally, records +.sync_state+,
242
+ # and attempts automatic unlock via the user's identity key slot.
243
+ #
244
+ # @param vault_name [String] vault to pull
245
+ # @param client [ApiClient] authenticated API client
246
+ # @param force [Boolean] overwrite existing local vault (default: false)
247
+ # @return [Boolean] true on success, false on any error
223
248
  def perform_pull(vault_name, client, force: false)
224
249
  store = Store.new(vault_name)
225
250
  if store.exists? && !force
@@ -267,7 +292,17 @@ module LocalVault
267
292
 
268
293
  # ── Classification ───────────────────────────────────────────
269
294
 
270
- def classify_vault(name, local_set, remote_map, my_handle)
295
+ # Determine the sync action for a single vault by comparing local,
296
+ # remote, and baseline state. Returns a hash with +:name+, +:action+
297
+ # (one of +:push+, +:pull+, +:skip+, +:conflict+), and +:reason+.
298
+ #
299
+ # @param name [String] vault name
300
+ # @param local_set [Set<String>] vaults that exist on disk
301
+ # @param remote_map [Hash{String => Hash}] remote vault info keyed by name
302
+ # @param my_handle [String] current user's InventList handle
303
+ # @param client [ApiClient] authenticated API client (used to hash remote secrets)
304
+ # @return [Hash] +{name:, action:, reason:}+
305
+ def classify_vault(name, local_set, remote_map, my_handle, client)
271
306
  l_exists = local_set.include?(name)
272
307
  r_info = remote_map[name]
273
308
  r_exists = !r_info.nil?
@@ -277,8 +312,25 @@ module LocalVault
277
312
  s_exists = ss.exists?
278
313
  baseline = ss.last_synced_checksum
279
314
 
280
- local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
281
- remote_checksum = r_info&.dig("checksum")
315
+ local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
316
+
317
+ # The server's list `checksum` hashes the whole sync bundle, which lives
318
+ # in a different hash space than our baseline and +local_checksum+ (both
319
+ # SHA256 of the encrypted *secrets* bytes). Comparing across those spaces
320
+ # never matches, so it would flag every both-exist vault as changed or
321
+ # conflicting. To compare like-for-like we download the bundle and hash
322
+ # its secrets bytes — but only when both sides exist, since for local-only
323
+ # / remote-only vaults the direction is unambiguous and no compare is needed.
324
+ if l_exists && r_exists
325
+ begin
326
+ remote_checksum = remote_secrets_checksum(name, client)
327
+ rescue SyncBundle::UnpackError
328
+ # A corrupt/unparseable remote bundle must not be conflated with an
329
+ # empty vault (both would otherwise yield nil and could falsely
330
+ # "adopt"). Surface it as a conflict the user can inspect.
331
+ return { name: name, action: :conflict, reason: "remote bundle unreadable — cannot compare" }
332
+ end
333
+ end
282
334
 
283
335
  # Ownership
284
336
  owner_handle = r_info&.dig("owner_handle")
@@ -294,6 +346,10 @@ module LocalVault
294
346
  { name: name, action: action, reason: reason }
295
347
  end
296
348
 
349
+ # Core decision matrix. Compares local/remote existence, checksums,
350
+ # and the stored baseline to decide: push, pull, skip, or conflict.
351
+ #
352
+ # @return [Array(Symbol, String)] +[action, reason]+ tuple
297
353
  def determine_action(l_exists, r_exists, s_exists,
298
354
  local_cs, remote_cs, baseline, is_read_only)
299
355
  # Only local
@@ -307,12 +363,14 @@ module LocalVault
307
363
  # Neither (shouldn't happen since we iterate union)
308
364
  return [:skip, "no data"] unless l_exists && r_exists
309
365
 
310
- # Both exist — no baseline (first sync for this vault)
366
+ # Both exist — no baseline (first sync for this vault). Compare the
367
+ # secrets bytes directly: if they match, the sides are already in sync
368
+ # and we just record a baseline so future syncs can detect drift.
311
369
  unless s_exists
312
- if local_cs == remote_cs || (local_cs.nil? && remote_cs.nil?)
313
- return [:skip, "already in sync (first check)"]
370
+ if local_cs == remote_cs
371
+ return [:adopt, "in sync recording baseline"]
314
372
  else
315
- return [:conflict, "both exist, no sync baseline — cannot determine which side changed"]
373
+ return [:conflict, "both exist, no sync baseline — run 'sync push' or 'sync pull' to resolve"]
316
374
  end
317
375
  end
318
376
 
@@ -333,6 +391,45 @@ module LocalVault
333
391
 
334
392
  # ── Helpers ──────────────────────────────────────────────────
335
393
 
394
+ # Record a sync baseline for a vault whose local and remote secrets are
395
+ # already byte-identical, without transferring any data. Lets the next
396
+ # sync detect drift instead of re-comparing from scratch every time.
397
+ #
398
+ # @param vault_name [String] vault to baseline
399
+ # @return [Boolean] true on success, false on any error
400
+ def perform_adopt(vault_name)
401
+ store = Store.new(vault_name)
402
+ SyncState.new(vault_name).write!(
403
+ checksum: SyncState.local_checksum(store),
404
+ direction: "adopt"
405
+ )
406
+ $stdout.puts " baselined #{vault_name} (already in sync)"
407
+ true
408
+ rescue StandardError => e
409
+ $stderr.puts "Error baselining '#{vault_name}': #{e.message}"
410
+ false
411
+ end
412
+
413
+ # SHA256 of a remote vault's encrypted secrets bytes — the same hash space
414
+ # as +SyncState.local_checksum+ and the stored baseline, so the two can be
415
+ # compared directly. Returns nil when the vault has no secrets (empty), is
416
+ # missing remotely (404), or the bundle can't be parsed.
417
+ #
418
+ # @param vault_name [String] vault to fetch
419
+ # @param client [ApiClient] authenticated API client
420
+ # @return [String, nil] hex digest of the remote secrets bytes, or nil if empty/missing
421
+ # @raise [SyncBundle::UnpackError] if the bundle exists but can't be parsed
422
+ def remote_secrets_checksum(vault_name, client)
423
+ blob = client.pull_vault(vault_name)
424
+ return nil unless blob.is_a?(String) && !blob.empty?
425
+ secrets = SyncBundle.unpack(blob)[:secrets]
426
+ return nil if secrets.nil? || secrets.empty?
427
+ Digest::SHA256.hexdigest(secrets)
428
+ rescue ApiClient::ApiError => e
429
+ raise unless e.status == 404
430
+ nil
431
+ end
432
+
336
433
  def try_unlock_via_key_slot(vault_name, key_slots)
337
434
  return false unless key_slots.is_a?(Hash) && !key_slots.empty?
338
435
  return false unless Identity.exists?
@@ -42,10 +42,11 @@ module LocalVault
42
42
  shell.say " localvault copy KEY --to V Copy a secret to another vault"
43
43
  shell.say ""
44
44
  shell.say "SYNC (requires localvault login)"
45
- shell.say " localvault sync push [NAME] Push vault to cloud"
46
- shell.say " localvault sync pull [NAME] Pull vault from cloud"
47
- shell.say " localvault sync status Show sync status"
48
- shell.say " localvault sync SUBCOMMAND See `localvault help sync` for full sync reference"
45
+ shell.say " localvault sync Sync all vaults bidirectionally (smart push/pull with conflict detection)"
46
+ shell.say " localvault sync --dry-run Show what would happen without making changes"
47
+ shell.say " localvault sync push [NAME] Push one vault to cloud"
48
+ shell.say " localvault sync pull [NAME] Pull one vault from cloud"
49
+ shell.say " localvault sync status Show sync status for all vaults"
49
50
  shell.say ""
50
51
  shell.say "TEAM SHARING (requires localvault login)"
51
52
  shell.say " localvault dashboard Aggregate view: owned vaults, vaults shared with you, legacy shares"
@@ -116,7 +116,11 @@ module LocalVault
116
116
  # Fall back to file store if Keychain fails (e.g., in CI or sandboxed env)
117
117
  unless success
118
118
  file = session_file(vault_name)
119
- File.write(file, payload, perm: 0o600)
119
+ File.write(file, payload)
120
+ # chmod unconditionally: File.write's perm: only applies on creation,
121
+ # so an existing looser-mode file would otherwise keep its perms and
122
+ # leak the master key.
123
+ File.chmod(0o600, file)
120
124
  end
121
125
  else
122
126
  keychain_delete(vault_name)
@@ -90,7 +90,7 @@ module LocalVault
90
90
  def self.unpack(blob, expected_name: nil)
91
91
  data = JSON.parse(blob)
92
92
  version = data["version"]
93
- raise UnpackError, "Unsupported bundle version: #{version}" if version && !SUPPORTED_VERSIONS.include?(version)
93
+ raise UnpackError, "Unsupported bundle version: #{version.inspect}" unless SUPPORTED_VERSIONS.include?(version)
94
94
 
95
95
  meta_raw = Base64.strict_decode64(data.fetch("meta"))
96
96
  secrets_raw = Base64.strict_decode64(data.fetch("secrets"))
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.6.0"
2
+ VERSION = "1.6.2"
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.6.0
4
+ version: 1.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq