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 +4 -4
- data/README.md +24 -13
- data/lib/localvault/cli/sync.rb +109 -12
- data/lib/localvault/cli.rb +5 -4
- 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
|
@@ -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
|
|
104
|
-
| `sync
|
|
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,
|
|
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
|
|
151
|
-
localvault sync
|
|
151
|
+
# Machine A: push all your vaults at once
|
|
152
|
+
localvault sync
|
|
152
153
|
|
|
153
|
-
# Machine B: install, login,
|
|
154
|
+
# Machine B: install, login, sync
|
|
154
155
|
brew install inventlist/tap/localvault
|
|
155
156
|
localvault login YOUR_TOKEN
|
|
156
|
-
localvault sync
|
|
157
|
-
localvault show
|
|
157
|
+
localvault sync # pulls everything, pushes local-only vaults
|
|
158
|
+
localvault show # enter your passphrase — same secrets
|
|
158
159
|
```
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
Or push/pull individual vaults:
|
|
161
162
|
|
|
162
163
|
```bash
|
|
163
|
-
localvault sync
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
281
|
-
|
|
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
|
|
313
|
-
return [:
|
|
370
|
+
if local_cs == remote_cs
|
|
371
|
+
return [:adopt, "in sync — recording baseline"]
|
|
314
372
|
else
|
|
315
|
-
return [:conflict, "both exist, no sync baseline —
|
|
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?
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -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
|
|
46
|
-
shell.say " localvault sync
|
|
47
|
-
shell.say " localvault sync
|
|
48
|
-
shell.say " localvault sync
|
|
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
|
|
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