localvault 1.6.1 → 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: 9cf502e2c11320a16bbf04e20ce1202d799d360775b664c1902c6ef337557302
4
- data.tar.gz: 1504ab893d079a1308519a8dd9fdfd941560348fca5d92cc1548edd9f6bf6549
3
+ metadata.gz: 3fe9cdf4fe4857588507532f6478318a7b02cdc47487bbecbe7ce54715e69e33
4
+ data.tar.gz: 1be1af8d9476e1171895a0ba0677e206832de1386416cd5fb7e7060e7b8b85f5
5
5
  SHA512:
6
- metadata.gz: 5aee20035eb1f290f95648c146b7a05f650f033d2f4cfa4ee4dbc565133031871607974c8c3eca58009bf1a3b5fff53ff11940c8841b476d281909d8d72d6ac4
7
- data.tar.gz: d8a9f184fc3c636742293a74901d24e12c7c8d48191b1816824e90cf0d7162f670373e78dd54ecc4bc6ef7daffbe6e73f1e341fdb786b258ba7ef3d8ffc1a4da
6
+ metadata.gz: 500451ad18462b5380839eb06b031b83b3acb91872430855e07038896c392f9488934aaf1649bd311886890400aaef9c45a062d8503c6db0760a2927b56a1650
7
+ data.tar.gz: aa1aa1b764c2de50ab0c8fb425b14a77e05085f2224131c7f53dc1eaaccc9b80732f160716a1826ef479c0d9653e5451074ca585927a86630ef68a92f23b9b8c
data/README.md CHANGED
@@ -105,7 +105,6 @@ localvault exec -- rails server
105
105
  | `sync push [NAME]` | Push one vault to cloud |
106
106
  | `sync pull [NAME]` | Pull one vault from cloud (auto-unlocks if you have a key slot) |
107
107
  | `sync status` | Show sync state for all vaults |
108
- | `config set server URL` | Point at a custom server (default: inventlist.com) |
109
108
 
110
109
  ### Team Sharing (v1.3.0)
111
110
 
@@ -139,7 +138,7 @@ backward compatibility but the top-level forms are preferred.
139
138
 
140
139
  | Command | Description |
141
140
  |---------|-------------|
142
- | `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 |
143
142
  | `mcp` | Start MCP server (stdio transport) |
144
143
 
145
144
  All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
@@ -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
 
@@ -292,8 +300,9 @@ module LocalVault
292
300
  # @param local_set [Set<String>] vaults that exist on disk
293
301
  # @param remote_map [Hash{String => Hash}] remote vault info keyed by name
294
302
  # @param my_handle [String] current user's InventList handle
303
+ # @param client [ApiClient] authenticated API client (used to hash remote secrets)
295
304
  # @return [Hash] +{name:, action:, reason:}+
296
- def classify_vault(name, local_set, remote_map, my_handle)
305
+ def classify_vault(name, local_set, remote_map, my_handle, client)
297
306
  l_exists = local_set.include?(name)
298
307
  r_info = remote_map[name]
299
308
  r_exists = !r_info.nil?
@@ -303,8 +312,25 @@ module LocalVault
303
312
  s_exists = ss.exists?
304
313
  baseline = ss.last_synced_checksum
305
314
 
306
- local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
307
- 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
308
334
 
309
335
  # Ownership
310
336
  owner_handle = r_info&.dig("owner_handle")
@@ -337,12 +363,14 @@ module LocalVault
337
363
  # Neither (shouldn't happen since we iterate union)
338
364
  return [:skip, "no data"] unless l_exists && r_exists
339
365
 
340
- # 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.
341
369
  unless s_exists
342
- if local_cs == remote_cs || (local_cs.nil? && remote_cs.nil?)
343
- return [:skip, "already in sync (first check)"]
370
+ if local_cs == remote_cs
371
+ return [:adopt, "in sync recording baseline"]
344
372
  else
345
- 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"]
346
374
  end
347
375
  end
348
376
 
@@ -363,6 +391,45 @@ module LocalVault
363
391
 
364
392
  # ── Helpers ──────────────────────────────────────────────────
365
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
+
366
433
  def try_unlock_via_key_slot(vault_name, key_slots)
367
434
  return false unless key_slots.is_a?(Hash) && !key_slots.empty?
368
435
  return false unless Identity.exists?
@@ -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.1"
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.1
4
+ version: 1.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq