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 +4 -4
- data/README.md +1 -2
- data/lib/localvault/cli/sync.rb +79 -12
- data/lib/localvault/session_cache.rb +5 -1
- data/lib/localvault/sync_bundle.rb +1 -1
- data/lib/localvault/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fe9cdf4fe4857588507532f6478318a7b02cdc47487bbecbe7ce54715e69e33
|
|
4
|
+
data.tar.gz: 1be1af8d9476e1171895a0ba0677e206832de1386416cd5fb7e7060e7b8b85f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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`.
|
data/lib/localvault/cli/sync.rb
CHANGED
|
@@ -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"
|
|
83
|
-
parts << "#{pulled} pulled"
|
|
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"
|
|
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
|
|
307
|
-
|
|
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
|
|
343
|
-
return [:
|
|
370
|
+
if local_cs == remote_cs
|
|
371
|
+
return [:adopt, "in sync — recording baseline"]
|
|
344
372
|
else
|
|
345
|
-
return [:conflict, "both exist, no sync baseline —
|
|
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
|
|
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}"
|
|
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"))
|
data/lib/localvault/version.rb
CHANGED