localvault 1.5.2 → 1.6.0

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: 6281a806d5f5838ea6802eaae4cb16a4276590636476151b45ed0d7ae639f1a9
4
- data.tar.gz: b1aad2994aff9c31e45759672a6f2fb35f77f5f5a3fabc24b897a112997abd80
3
+ metadata.gz: 6c3cc5ee20ea5b0017e6ce6b7879234c36d2c30f6a507ca8dd119c9b24a32fa9
4
+ data.tar.gz: 28c32bd08489c90748782c8320bca666e95a1cf71cf5deb22bcb443bf776e406
5
5
  SHA512:
6
- metadata.gz: b4b948bc2126f8ac488190d3f4d07f1ebe995d32baaf7fb458b95256507f62438d09e472cbcc059cf23d2bb91a63f05ffcd11d5585d6c0a4fec827fdc667d4cd
7
- data.tar.gz: a32de3dd3fde6cba7da103d7c6d76924b236604e1cd80c28873a47dba01f52e0f99f2db7f44121a5343845e1a41a5644ff42548d96528b8c1551d0c2f47c3a10
6
+ metadata.gz: efd141b468fe4f8c0ec3b1b180ef492c3521f074574bbb600ec0d2cc871927df73d7db4c06093eb25444816ae0fa7b92452470f41b65ad7b959a1f6e8a1511b8
7
+ data.tar.gz: c385bc4e21bec7ca233633e452496589a626f6706672270efc3365b4036f1da1c0274809a96d4d4880f6ba519bf319c7f860afe644f742d01d657e3b3d8327e1
@@ -1,95 +1,234 @@
1
1
  require "thor"
2
2
  require "fileutils"
3
+ require "digest"
3
4
 
4
5
  module LocalVault
5
6
  class CLI
6
7
  class Sync < Thor
7
- desc "push [NAME]", "Push a vault to InventList cloud sync"
8
- # Push a local vault to InventList cloud sync.
8
+ desc "all", "Sync all vaults bidirectionally (push local changes, pull remote changes)"
9
+ method_option :dry_run, type: :boolean, default: false, desc: "Show what would happen without making changes"
10
+ # Smart bidirectional sync for all vaults.
11
+ #
12
+ # Uses per-vault .sync_state files (written by push/pull) to track
13
+ # the last-synced checksum and detect what changed on each side:
9
14
  #
10
- # Packs the vault's meta and encrypted secrets into a SyncBundle and uploads
11
- # it. Preserves existing key slots from the remote and bootstraps an owner
12
- # slot if the current identity has no slot yet. Defaults to the configured
13
- # default vault if no name is given.
15
+ # - Local-only vault push
16
+ # - Remote-only vault pull
17
+ # - Both exist, only local changed push
18
+ # - Both exist, only remote changed → pull
19
+ # - Both exist, neither changed → skip
20
+ # - Both exist, both changed → CONFLICT (manual resolution)
21
+ # - Shared vault (not owned by you) → pull-only
22
+ def all
23
+ return unless logged_in?
24
+
25
+ client = ApiClient.new(token: Config.token)
26
+ my_handle = Config.inventlist_handle
27
+ result = client.list_vaults
28
+ remote_map = (result["vaults"] || []).each_with_object({}) { |v, h| h[v["name"]] = v }
29
+ local_set = Store.list_vaults.to_set
30
+ all_names = (remote_map.keys + local_set.to_a).uniq.sort
31
+
32
+ if all_names.empty?
33
+ $stdout.puts "No vaults to sync."
34
+ return
35
+ end
36
+
37
+ plan = all_names.map { |name| classify_vault(name, local_set, remote_map, my_handle) }
38
+
39
+ # Print plan
40
+ max_name = (["Vault"] + plan.map { |p| p[:name] }).map(&:length).max
41
+ max_action = (["Action"] + plan.map { |p| p[:action].to_s }).map(&:length).max
42
+
43
+ $stdout.puts
44
+ $stdout.puts " #{"Vault".ljust(max_name)} #{"Action".ljust(max_action)} Reason"
45
+ $stdout.puts " #{"─" * max_name} #{"─" * max_action} ──────"
46
+ plan.each do |p|
47
+ label = p[:action] == :conflict ? "CONFLICT" : p[:action].to_s
48
+ $stdout.puts " #{p[:name].ljust(max_name)} #{label.ljust(max_action)} #{p[:reason]}"
49
+ end
50
+ $stdout.puts
51
+
52
+ if options[:dry_run]
53
+ $stdout.puts "Dry run — no changes made."
54
+ return
55
+ end
56
+
57
+ # Execute
58
+ pushed = pulled = skipped = conflicts = errors = 0
59
+ plan.each do |entry|
60
+ case entry[:action]
61
+ when :push
62
+ if perform_push(entry[:name], client)
63
+ pushed += 1
64
+ else
65
+ errors += 1
66
+ end
67
+ when :pull
68
+ if perform_pull(entry[:name], client, force: true)
69
+ pulled += 1
70
+ else
71
+ errors += 1
72
+ end
73
+ when :skip
74
+ skipped += 1
75
+ when :conflict
76
+ conflicts += 1
77
+ end
78
+ end
79
+
80
+ # Summary
81
+ parts = []
82
+ parts << "#{pushed} pushed" if pushed > 0
83
+ parts << "#{pulled} pulled" if pulled > 0
84
+ parts << "#{skipped} up to date" if skipped > 0
85
+ parts << "#{errors} failed" if errors > 0
86
+ parts << "#{conflicts} conflict#{conflicts == 1 ? "" : "s"}" if conflicts > 0
87
+ $stdout.puts "Summary: #{parts.join(", ")}"
88
+
89
+ # Conflict guidance
90
+ if conflicts > 0
91
+ $stdout.puts
92
+ plan.select { |p| p[:action] == :conflict }.each do |p|
93
+ $stderr.puts " #{p[:name]} — #{p[:reason]}"
94
+ $stderr.puts " Resolve with:"
95
+ $stderr.puts " localvault sync push #{p[:name]} (keep local, overwrite remote)"
96
+ $stderr.puts " localvault sync pull #{p[:name]} --force (keep remote, overwrite local)"
97
+ end
98
+ end
99
+ rescue ApiClient::ApiError => e
100
+ $stderr.puts "Error: #{e.message}"
101
+ end
102
+
103
+ default_task :all
104
+
105
+ desc "push [NAME]", "Push a vault to InventList cloud sync"
14
106
  def push(vault_name = nil)
15
107
  return unless logged_in?
108
+ vault_name ||= Config.default_vault
109
+ client = ApiClient.new(token: Config.token)
110
+ perform_push(vault_name, client)
111
+ end
16
112
 
113
+ desc "pull [NAME]", "Pull a vault from InventList cloud sync"
114
+ method_option :force, type: :boolean, default: false, desc: "Overwrite existing local vault"
115
+ def pull(vault_name = nil)
116
+ return unless logged_in?
17
117
  vault_name ||= Config.default_vault
18
- store = Store.new(vault_name)
118
+ client = ApiClient.new(token: Config.token)
119
+ perform_pull(vault_name, client, force: options[:force])
120
+ end
121
+
122
+ desc "status", "Show sync status for all vaults"
123
+ def status
124
+ return unless logged_in?
125
+
126
+ client = ApiClient.new(token: Config.token)
127
+ result = client.list_vaults
128
+ remote = (result["vaults"] || []).each_with_object({}) { |v, h| h[v["name"]] = v }
129
+ local_set = Store.list_vaults.to_set
130
+ all_names = (remote.keys + local_set.to_a).uniq.sort
131
+
132
+ if all_names.empty?
133
+ $stdout.puts "No vaults found locally or in cloud."
134
+ return
135
+ end
136
+
137
+ rows = all_names.map do |name|
138
+ r = remote[name]
139
+ l_exists = local_set.include?(name)
140
+ row_status = if r && l_exists then "synced"
141
+ elsif r then "remote only"
142
+ else "local only"
143
+ end
144
+ synced_at = r ? (r["synced_at"]&.slice(0, 10) || "—") : "—"
145
+ [name, row_status, synced_at]
146
+ end
147
+
148
+ max_name = (["Vault"] + rows.map { |r| r[0] }).map(&:length).max
149
+ max_status = (["Status"] + rows.map { |r| r[1] }).map(&:length).max
150
+
151
+ $stdout.puts "#{"Vault".ljust(max_name)} #{"Status".ljust(max_status)} Synced At"
152
+ $stdout.puts "#{"─" * max_name} #{"─" * max_status} ─────────"
153
+ rows.each do |name, row_status, synced_at|
154
+ $stdout.puts "#{name.ljust(max_name)} #{row_status.ljust(max_status)} #{synced_at}"
155
+ end
156
+ rescue ApiClient::ApiError => e
157
+ $stderr.puts "Error: #{e.message}"
158
+ end
159
+
160
+ def self.exit_on_failure?
161
+ true
162
+ end
19
163
 
164
+ private
165
+
166
+ # ── Core push logic ──────────────────────────────────────────
167
+
168
+ def perform_push(vault_name, client)
169
+ store = Store.new(vault_name)
20
170
  unless store.exists?
21
171
  $stderr.puts "Error: Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
22
- return
172
+ return false
23
173
  end
24
174
 
25
- # Load remote state to determine vault mode. MUST distinguish a
26
- # genuinely-absent remote (404) from a transient API failure: treating
27
- # a 5xx as "no remote" would silently downgrade a team vault to a
28
- # personal v1 bundle on the next push.
29
175
  remote_data, load_error = load_remote_bundle_data(vault_name)
30
176
  if load_error
31
177
  $stderr.puts "Error: #{load_error}"
32
- $stderr.puts "Refusing to push — cannot verify vault mode. Retry when the server is reachable."
33
- return
178
+ $stderr.puts "Refusing to push — cannot verify vault mode."
179
+ return false
34
180
  end
181
+
35
182
  handle = Config.inventlist_handle
36
183
 
37
184
  if remote_data && remote_data[:owner]
38
- # Team vault — check push authorization
39
- owner = remote_data[:owner]
185
+ owner = remote_data[:owner]
40
186
  key_slots = remote_data[:key_slots] || {}
41
187
  has_scoped = key_slots.values.any? { |s| s.is_a?(Hash) && s["scopes"].is_a?(Array) }
42
- my_slot = key_slots[handle]
43
- am_scoped = my_slot.is_a?(Hash) && my_slot["scopes"].is_a?(Array)
188
+ my_slot = key_slots[handle]
189
+ am_scoped = my_slot.is_a?(Hash) && my_slot["scopes"].is_a?(Array)
44
190
 
45
191
  if am_scoped
46
192
  $stderr.puts "Error: You have scoped access to vault '#{vault_name}'. Only the owner (@#{owner}) can push."
47
- return
193
+ return false
48
194
  end
49
-
50
195
  if has_scoped && owner != handle
51
196
  $stderr.puts "Error: Vault '#{vault_name}' has scoped members. Only the owner (@#{owner}) can push."
52
- return
197
+ return false
53
198
  end
54
199
 
55
- # Authorized — push as v3, preserve key_slots
56
200
  key_slots = bootstrap_owner_slot(key_slots, store)
57
201
  blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
58
202
  else
59
- # Personal vault — push as v1
60
203
  blob = SyncBundle.pack(store)
61
204
  end
62
205
 
63
- client = ApiClient.new(token: Config.token)
64
206
  client.push_vault(vault_name, blob)
65
207
 
66
- $stdout.puts "Synced vault '#{vault_name}' (#{blob.bytesize} bytes)"
208
+ # Record sync state
209
+ SyncState.new(vault_name).write!(
210
+ checksum: SyncState.local_checksum(store),
211
+ direction: "push"
212
+ )
213
+
214
+ $stdout.puts " pushed #{vault_name} (#{blob.bytesize} bytes)"
215
+ true
67
216
  rescue ApiClient::ApiError => e
68
- $stderr.puts "Error: #{e.message}"
217
+ $stderr.puts "Error pushing '#{vault_name}': #{e.message}"
218
+ false
69
219
  end
70
220
 
71
- desc "pull [NAME]", "Pull a vault from InventList cloud sync"
72
- method_option :force, type: :boolean, default: false, desc: "Overwrite existing local vault"
73
- # Pull a vault from InventList cloud sync to the local filesystem.
74
- #
75
- # Downloads the SyncBundle, writes meta.yml and secrets.enc locally, and
76
- # attempts automatic unlock via key slot. Refuses to overwrite an existing
77
- # local vault unless +--force+ is passed. Defaults to the configured default
78
- # vault if no name is given.
79
- def pull(vault_name = nil)
80
- return unless logged_in?
81
-
82
- vault_name ||= Config.default_vault
83
- store = Store.new(vault_name)
221
+ # ── Core pull logic ──────────────────────────────────────────
84
222
 
85
- if store.exists? && !options[:force]
223
+ def perform_pull(vault_name, client, force: false)
224
+ store = Store.new(vault_name)
225
+ if store.exists? && !force
86
226
  $stderr.puts "Error: Vault '#{vault_name}' already exists locally. Use --force to overwrite."
87
- return
227
+ return false
88
228
  end
89
229
 
90
- client = ApiClient.new(token: Config.token)
91
- blob = client.pull_vault(vault_name)
92
- data = SyncBundle.unpack(blob, expected_name: vault_name)
230
+ blob = client.pull_vault(vault_name)
231
+ data = SyncBundle.unpack(blob, expected_name: vault_name)
93
232
 
94
233
  FileUtils.mkdir_p(store.vault_path, mode: 0o700)
95
234
  File.write(store.meta_path, data[:meta])
@@ -100,78 +239,100 @@ module LocalVault
100
239
  store.write_encrypted(data[:secrets])
101
240
  end
102
241
 
103
- $stdout.puts "Pulled vault '#{vault_name}'."
242
+ # Record sync state
243
+ SyncState.new(vault_name).write!(
244
+ checksum: SyncState.local_checksum(store),
245
+ direction: "pull"
246
+ )
247
+
248
+ $stdout.puts " pulled #{vault_name}"
104
249
 
105
250
  if try_unlock_via_key_slot(vault_name, data[:key_slots])
106
- $stdout.puts "Unlocked via your identity key."
251
+ $stdout.puts " unlocked via identity key"
107
252
  else
108
- $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
253
+ $stdout.puts " unlock it with: localvault unlock #{vault_name}"
109
254
  end
255
+ true
110
256
  rescue SyncBundle::UnpackError => e
111
- $stderr.puts "Error: #{e.message}"
257
+ $stderr.puts "Error pulling '#{vault_name}': #{e.message}"
258
+ false
112
259
  rescue ApiClient::ApiError => e
113
260
  if e.status == 404
114
261
  $stderr.puts "Error: Vault '#{vault_name}' not found in cloud."
115
262
  else
116
- $stderr.puts "Error: #{e.message}"
263
+ $stderr.puts "Error pulling '#{vault_name}': #{e.message}"
117
264
  end
265
+ false
118
266
  end
119
267
 
120
- desc "status", "Show sync status for all vaults"
121
- # Display sync status for all local and remote vaults.
122
- #
123
- # Shows a table with vault name, status (synced / remote only / local only),
124
- # and last sync timestamp. Compares local vaults against the cloud inventory.
125
- def status
126
- return unless logged_in?
268
+ # ── Classification ───────────────────────────────────────────
127
269
 
128
- client = ApiClient.new(token: Config.token)
129
- result = client.list_vaults
130
- remote = (result["vaults"] || []).each_with_object({}) { |v, h| h[v["name"]] = v }
131
- local_set = Store.list_vaults.to_set
132
- all_names = (remote.keys + local_set.to_a).uniq.sort
270
+ def classify_vault(name, local_set, remote_map, my_handle)
271
+ l_exists = local_set.include?(name)
272
+ r_info = remote_map[name]
273
+ r_exists = !r_info.nil?
133
274
 
134
- if all_names.empty?
135
- $stdout.puts "No vaults found locally or in cloud."
136
- return
137
- end
275
+ store = l_exists ? Store.new(name) : nil
276
+ ss = SyncState.new(name)
277
+ s_exists = ss.exists?
278
+ baseline = ss.last_synced_checksum
138
279
 
139
- rows = all_names.map do |name|
140
- r = remote[name]
141
- l_exists = local_set.include?(name)
142
- row_status = if r && l_exists then "synced"
143
- elsif r then "remote only"
144
- else "local only"
145
- end
146
- synced_at = r ? (r["synced_at"]&.slice(0, 10) || "—") : "—"
147
- [name, row_status, synced_at]
280
+ local_checksum = l_exists && store ? SyncState.local_checksum(store) : nil
281
+ remote_checksum = r_info&.dig("checksum")
282
+
283
+ # Ownership
284
+ owner_handle = r_info&.dig("owner_handle")
285
+ is_shared = r_info&.dig("shared") == true
286
+ is_read_only = is_shared || (owner_handle && owner_handle != my_handle)
287
+
288
+ action, reason = determine_action(
289
+ l_exists, r_exists, s_exists,
290
+ local_checksum, remote_checksum, baseline,
291
+ is_read_only
292
+ )
293
+
294
+ { name: name, action: action, reason: reason }
295
+ end
296
+
297
+ def determine_action(l_exists, r_exists, s_exists,
298
+ local_cs, remote_cs, baseline, is_read_only)
299
+ # Only local
300
+ if l_exists && !r_exists
301
+ return is_read_only ? [:skip, "shared vault, local copy only"] : [:push, "local only"]
148
302
  end
149
303
 
150
- max_name = (["Vault"] + rows.map { |r| r[0] }).map(&:length).max
151
- max_status = (["Status"] + rows.map { |r| r[1] }).map(&:length).max
304
+ # Only remote
305
+ return [:pull, "remote only"] if !l_exists && r_exists
152
306
 
153
- $stdout.puts "#{"Vault".ljust(max_name)} #{"Status".ljust(max_status)} Synced At"
154
- $stdout.puts "#{"─" * max_name} #{"─" * max_status} ─────────"
155
- rows.each do |name, row_status, synced_at|
156
- $stdout.puts "#{name.ljust(max_name)} #{row_status.ljust(max_status)} #{synced_at}"
307
+ # Neither (shouldn't happen since we iterate union)
308
+ return [:skip, "no data"] unless l_exists && r_exists
309
+
310
+ # Both exist — no baseline (first sync for this vault)
311
+ unless s_exists
312
+ if local_cs == remote_cs || (local_cs.nil? && remote_cs.nil?)
313
+ return [:skip, "already in sync (first check)"]
314
+ else
315
+ return [:conflict, "both exist, no sync baseline — cannot determine which side changed"]
316
+ end
157
317
  end
158
- rescue ApiClient::ApiError => e
159
- $stderr.puts "Error: #{e.message}"
160
- end
161
318
 
162
- def self.exit_on_failure?
163
- true
319
+ # Both exist, have baseline
320
+ local_changed = local_cs != baseline
321
+ remote_changed = remote_cs != baseline
322
+
323
+ if !local_changed && !remote_changed
324
+ [:skip, "up to date"]
325
+ elsif local_changed && !remote_changed
326
+ is_read_only ? [:skip, "shared vault (local edits, pull-only)"] : [:push, "local changes"]
327
+ elsif !local_changed && remote_changed
328
+ [:pull, "remote changes"]
329
+ else
330
+ [:conflict, "both local and remote changed since last sync"]
331
+ end
164
332
  end
165
333
 
166
- private
334
+ # ── Helpers ──────────────────────────────────────────────────
167
335
 
168
- # Try to decrypt via key slot matching the current identity.
169
- #
170
- # For full-access members (scopes: nil): decrypts enc_key to get the master key.
171
- # For scoped members (scopes: [...]): decrypts enc_key to get the per-member key,
172
- # then decrypts the per-member blob and writes it as the local vault's secrets.
173
- #
174
- # On success, caches the key in SessionCache. Returns true/false.
175
336
  def try_unlock_via_key_slot(vault_name, key_slots)
176
337
  return false unless key_slots.is_a?(Hash) && !key_slots.empty?
177
338
  return false unless Identity.exists?
@@ -185,22 +346,16 @@ module LocalVault
185
346
  decrypted_key = KeySlot.decrypt(slot["enc_key"], Identity.private_key_bytes)
186
347
 
187
348
  if slot["scopes"].is_a?(Array) && slot["blob"].is_a?(String)
188
- # Scoped member: decrypt per-member blob and write as local vault
189
349
  blob_encrypted = Base64.strict_decode64(slot["blob"])
190
350
  filtered_json = Crypto.decrypt(blob_encrypted, decrypted_key)
191
- # Verify it's valid JSON
192
351
  JSON.parse(filtered_json)
193
352
 
194
- # Re-encrypt the filtered secrets with the member key as local "master key"
195
353
  store = Store.new(vault_name)
196
354
  store.write_encrypted(Crypto.encrypt(filtered_json, decrypted_key))
197
-
198
355
  SessionCache.set(vault_name, decrypted_key)
199
356
  else
200
- # Full-access member: decrypted_key IS the master key
201
357
  vault = Vault.new(name: vault_name, master_key: decrypted_key)
202
- vault.all # verify
203
-
358
+ vault.all
204
359
  SessionCache.set(vault_name, decrypted_key)
205
360
  end
206
361
  true
@@ -221,16 +376,6 @@ module LocalVault
221
376
  false
222
377
  end
223
378
 
224
- # Load the full unpacked remote bundle data (owner, key_slots, etc).
225
- # Returns [data, error_message]:
226
- # - [hash, nil] on success
227
- # - [nil, nil] when there genuinely is no remote bundle (404 / empty)
228
- # - [nil, msg] on any other failure (transient network, 5xx, bad bundle)
229
- #
230
- # The caller MUST distinguish these cases — treating a transient error
231
- # as "no remote" is a data-corruption bug: sync push would then re-upload
232
- # the vault as a v1 personal bundle, silently downgrading a team vault
233
- # and wiping its owner + key_slots.
234
379
  def load_remote_bundle_data(vault_name)
235
380
  client = ApiClient.new(token: Config.token)
236
381
  blob = client.pull_vault(vault_name)
@@ -243,8 +388,6 @@ module LocalVault
243
388
  [nil, "Could not parse remote bundle for '#{vault_name}': #{e.message}"]
244
389
  end
245
390
 
246
- # Load key_slots from the last pushed blob (if any).
247
- # Returns {} if no remote blob or if it's a v1 bundle.
248
391
  def load_existing_key_slots(vault_name)
249
392
  client = ApiClient.new(token: Config.token)
250
393
  blob = client.pull_vault(vault_name)
@@ -255,17 +398,12 @@ module LocalVault
255
398
  {}
256
399
  end
257
400
 
258
- # Add the owner's key slot if identity exists and no slot is present.
259
- # Requires the vault to be unlockable (needs master key for encryption).
260
401
  def bootstrap_owner_slot(key_slots, store)
261
402
  return key_slots unless Identity.exists?
262
403
  handle = Config.inventlist_handle
263
404
  return key_slots unless handle
264
-
265
- # Already has owner slot — don't churn
266
405
  return key_slots if key_slots.key?(handle)
267
406
 
268
- # Need the master key to create the slot — try SessionCache
269
407
  master_key = SessionCache.get(store.vault_name)
270
408
  return key_slots unless master_key
271
409
 
@@ -0,0 +1,71 @@
1
+ require "yaml"
2
+ require "digest"
3
+ require "time"
4
+
5
+ module LocalVault
6
+ # Per-vault sync state tracking. Stores the checksum of secrets.enc at the
7
+ # time of the last successful push or pull, so the next `localvault sync`
8
+ # can determine whether local, remote, or both sides have changed.
9
+ #
10
+ # Stored as `~/.localvault/vaults/<name>/.sync_state` (YAML, mode 0600).
11
+ # Separate from meta.yml because meta.yml is part of the SyncBundle and
12
+ # including sync bookkeeping there would create a checksum feedback loop.
13
+ class SyncState
14
+ FILENAME = ".sync_state"
15
+
16
+ attr_reader :vault_name
17
+
18
+ def initialize(vault_name)
19
+ @vault_name = vault_name
20
+ end
21
+
22
+ def path
23
+ File.join(Config.vaults_path, vault_name, FILENAME)
24
+ end
25
+
26
+ def exists?
27
+ File.exist?(path)
28
+ end
29
+
30
+ # @return [Hash, nil] parsed YAML data or nil
31
+ def read
32
+ return nil unless exists?
33
+ YAML.safe_load_file(path)
34
+ rescue Psych::SyntaxError
35
+ nil
36
+ end
37
+
38
+ def last_synced_checksum
39
+ read&.dig("last_synced_checksum")
40
+ end
41
+
42
+ def last_synced_at
43
+ read&.dig("last_synced_at")
44
+ end
45
+
46
+ # Record a successful sync operation.
47
+ #
48
+ # @param checksum [String] SHA256 hex of the local secrets.enc
49
+ # @param direction [String] "push" or "pull"
50
+ def write!(checksum:, direction:)
51
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
52
+ data = {
53
+ "last_synced_checksum" => checksum,
54
+ "last_synced_at" => Time.now.utc.iso8601,
55
+ "direction" => direction
56
+ }
57
+ File.write(path, YAML.dump(data))
58
+ File.chmod(0o600, path)
59
+ end
60
+
61
+ # Compute the SHA256 hex digest of a vault's local secrets.enc.
62
+ #
63
+ # @param store [Store] vault store
64
+ # @return [String, nil] hex digest or nil if no secrets file
65
+ def self.local_checksum(store)
66
+ bytes = store.read_encrypted
67
+ return nil if bytes.nil? || bytes.empty?
68
+ Digest::SHA256.hexdigest(bytes)
69
+ end
70
+ end
71
+ end
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "1.5.2"
2
+ VERSION = "1.6.0"
3
3
  end
data/lib/localvault.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "localvault/share_crypto"
8
8
  require_relative "localvault/key_slot"
9
9
  require_relative "localvault/api_client"
10
10
  require_relative "localvault/sync_bundle"
11
+ require_relative "localvault/sync_state"
11
12
 
12
13
  module LocalVault
13
14
  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.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -122,6 +122,7 @@ files:
122
122
  - lib/localvault/share_crypto.rb
123
123
  - lib/localvault/store.rb
124
124
  - lib/localvault/sync_bundle.rb
125
+ - lib/localvault/sync_state.rb
125
126
  - lib/localvault/vault.rb
126
127
  - lib/localvault/version.rb
127
128
  homepage: https://github.com/inventlist/localvault