localvault 0.9.6 → 0.9.8

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: 671f50a3af0358b5344e1ee94f39cee7b74c7d45c5a55fce9e42da3b1f5b3ffc
4
- data.tar.gz: a7f41141bff442117b919c5aba3864d51830aedf32ca8ef0ee025bd15bd5ea57
3
+ metadata.gz: fb41c4ac2d8dd408f4e1add9e9d79a7ec8f3f9fc139a8c8575008d0c0a4dd53d
4
+ data.tar.gz: d530b0a5e4924f270fe7386a6efb46feaf8ebd27f4b6474b288a6567fc31a9a5
5
5
  SHA512:
6
- metadata.gz: bde2aaed764d7317de96dda2f962a569fe4a9ae9084e14971ee7a0f838d9c2c0a698bf0f3c0ba151f6179b16522ca61a368f5b881b9491d5db7ab74ec8f50e43
7
- data.tar.gz: dfdeb12993ae4236e98b8b19424b9b03d60f29f842c4586f5f31b4dfeef13e44791e04f0b53d50fb14fb2794bc2939b69eedb71c1029b614a907868a27df0958
6
+ metadata.gz: 48c14b4bca2e1de6e48e35f8fba1a44bfb63d2291d86e6a60b4c07baa9b07d77180b8e511c9ad1a93c80681f4ec4c17c885707bb6c50b24545f973c4d770adb2
7
+ data.tar.gz: 56c1b8aa333146e7d2553e5c8a81dbaa8077f4185f5b76208086e72a886f2db253e8334232076ca7b6c16ee16bebad391713cbe472f1db75978bfbb7a6107370
@@ -20,6 +20,11 @@ module LocalVault
20
20
  @base_url = base_url || Config.api_url
21
21
  end
22
22
 
23
+ # GET /api/v1/me
24
+ def me
25
+ get("/me")
26
+ end
27
+
23
28
  # GET /api/v1/users/:handle/public_key
24
29
  def get_public_key(handle)
25
30
  get("/users/#{handle}/public_key")
@@ -71,6 +76,26 @@ module LocalVault
71
76
  get("/sites/#{site_slug}/crew/public_keys")
72
77
  end
73
78
 
79
+ # GET /api/v1/vaults
80
+ def list_vaults
81
+ get("/vaults")
82
+ end
83
+
84
+ # PUT /api/v1/vaults/:name — sends raw binary blob, returns JSON
85
+ def push_vault(name, blob)
86
+ request_binary(:put, "/vaults/#{URI.encode_uri_component(name)}", blob)
87
+ end
88
+
89
+ # GET /api/v1/vaults/:name — returns raw binary blob
90
+ def pull_vault(name)
91
+ request_raw(:get, "/vaults/#{URI.encode_uri_component(name)}")
92
+ end
93
+
94
+ # DELETE /api/v1/vaults/:name
95
+ def delete_vault(name)
96
+ delete("/vaults/#{URI.encode_uri_component(name)}")
97
+ end
98
+
74
99
  private
75
100
 
76
101
  def get(path)
@@ -121,5 +146,49 @@ module LocalVault
121
146
  rescue Errno::ECONNREFUSED, SocketError => e
122
147
  raise ApiError.new("Cannot connect to #{@base_url}: #{e.message}")
123
148
  end
149
+
150
+ # PUT /api/v1/vaults/:name — sends raw binary body, expects JSON response
151
+ def request_binary(method, path, blob)
152
+ uri = URI("#{@base_url}#{BASE_PATH}#{path}")
153
+ http = Net::HTTP.new(uri.host, uri.port)
154
+ http.use_ssl = uri.scheme == "https"
155
+
156
+ req_class = { put: Net::HTTP::Put }.fetch(method)
157
+ req = req_class.new(uri.request_uri)
158
+ req["Authorization"] = "Bearer #{@token}"
159
+ req["Content-Type"] = "application/octet-stream"
160
+ req["Accept"] = "application/json"
161
+ req.body = blob
162
+
163
+ res = http.request(req)
164
+ unless res.is_a?(Net::HTTPSuccess)
165
+ err = begin JSON.parse(res.body)["error"] rescue nil end
166
+ raise ApiError.new(err || "HTTP #{res.code}", status: res.code.to_i)
167
+ end
168
+ JSON.parse(res.body)
169
+ rescue Errno::ECONNREFUSED, SocketError => e
170
+ raise ApiError.new("Cannot connect to #{@base_url}: #{e.message}")
171
+ end
172
+
173
+ # GET /api/v1/vaults/:name — returns raw binary body
174
+ def request_raw(method, path)
175
+ uri = URI("#{@base_url}#{BASE_PATH}#{path}")
176
+ http = Net::HTTP.new(uri.host, uri.port)
177
+ http.use_ssl = uri.scheme == "https"
178
+
179
+ req_class = { get: Net::HTTP::Get }.fetch(method)
180
+ req = req_class.new(uri.request_uri)
181
+ req["Authorization"] = "Bearer #{@token}"
182
+ req["Accept"] = "application/octet-stream"
183
+
184
+ res = http.request(req)
185
+ unless res.is_a?(Net::HTTPSuccess)
186
+ err = begin JSON.parse(res.body)["error"] rescue nil end
187
+ raise ApiError.new(err || "HTTP #{res.code}", status: res.code.to_i)
188
+ end
189
+ res.body
190
+ rescue Errno::ECONNREFUSED, SocketError => e
191
+ raise ApiError.new("Cannot connect to #{@base_url}: #{e.message}")
192
+ end
124
193
  end
125
194
  end
@@ -0,0 +1,111 @@
1
+ require "thor"
2
+ require "fileutils"
3
+
4
+ module LocalVault
5
+ class CLI
6
+ class Sync < Thor
7
+ desc "push [NAME]", "Push a vault to InventList cloud sync"
8
+ def push(vault_name = nil)
9
+ return unless logged_in?
10
+
11
+ vault_name ||= Config.default_vault
12
+ store = Store.new(vault_name)
13
+
14
+ unless store.exists?
15
+ $stderr.puts "Error: Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
16
+ return
17
+ end
18
+
19
+ blob = SyncBundle.pack(store)
20
+ client = ApiClient.new(token: Config.token)
21
+ client.push_vault(vault_name, blob)
22
+
23
+ $stdout.puts "Synced vault '#{vault_name}' (#{blob.bytesize} bytes)"
24
+ rescue ApiClient::ApiError => e
25
+ $stderr.puts "Error: #{e.message}"
26
+ end
27
+
28
+ desc "pull [NAME]", "Pull a vault from InventList cloud sync"
29
+ method_option :force, type: :boolean, default: false, desc: "Overwrite existing local vault"
30
+ def pull(vault_name = nil)
31
+ return unless logged_in?
32
+
33
+ vault_name ||= Config.default_vault
34
+ store = Store.new(vault_name)
35
+
36
+ if store.exists? && !options[:force]
37
+ $stderr.puts "Error: Vault '#{vault_name}' already exists locally. Use --force to overwrite."
38
+ return
39
+ end
40
+
41
+ client = ApiClient.new(token: Config.token)
42
+ blob = client.pull_vault(vault_name)
43
+ data = SyncBundle.unpack(blob)
44
+
45
+ FileUtils.mkdir_p(store.vault_path)
46
+ File.write(store.meta_path, data[:meta])
47
+ store.write_encrypted(data[:secrets]) unless data[:secrets].empty?
48
+
49
+ $stdout.puts "Pulled vault '#{vault_name}'."
50
+ $stdout.puts "Unlock it with: localvault unlock -v #{vault_name}"
51
+ rescue ApiClient::ApiError => e
52
+ if e.status == 404
53
+ $stderr.puts "Error: Vault '#{vault_name}' not found in cloud."
54
+ else
55
+ $stderr.puts "Error: #{e.message}"
56
+ end
57
+ end
58
+
59
+ desc "status", "Show sync status for all vaults"
60
+ def status
61
+ return unless logged_in?
62
+
63
+ client = ApiClient.new(token: Config.token)
64
+ result = client.list_vaults
65
+ remote = (result["vaults"] || []).each_with_object({}) { |v, h| h[v["name"]] = v }
66
+ local_set = Store.list_vaults.to_set
67
+ all_names = (remote.keys + local_set.to_a).uniq.sort
68
+
69
+ if all_names.empty?
70
+ $stdout.puts "No vaults found locally or in cloud."
71
+ return
72
+ end
73
+
74
+ rows = all_names.map do |name|
75
+ r = remote[name]
76
+ l_exists = local_set.include?(name)
77
+ row_status = if r && l_exists then "synced"
78
+ elsif r then "remote only"
79
+ else "local only"
80
+ end
81
+ synced_at = r ? (r["synced_at"]&.slice(0, 10) || "—") : "—"
82
+ [name, row_status, synced_at]
83
+ end
84
+
85
+ max_name = (["Vault"] + rows.map { |r| r[0] }).map(&:length).max
86
+ max_status = (["Status"] + rows.map { |r| r[1] }).map(&:length).max
87
+
88
+ $stdout.puts "#{"Vault".ljust(max_name)} #{"Status".ljust(max_status)} Synced At"
89
+ $stdout.puts "#{"─" * max_name} #{"─" * max_status} ─────────"
90
+ rows.each do |name, row_status, synced_at|
91
+ $stdout.puts "#{name.ljust(max_name)} #{row_status.ljust(max_status)} #{synced_at}"
92
+ end
93
+ rescue ApiClient::ApiError => e
94
+ $stderr.puts "Error: #{e.message}"
95
+ end
96
+
97
+ def self.exit_on_failure?
98
+ true
99
+ end
100
+
101
+ private
102
+
103
+ def logged_in?
104
+ return true if Config.token
105
+
106
+ $stderr.puts "Error: Not logged in. Run: localvault login TOKEN"
107
+ false
108
+ end
109
+ end
110
+ end
111
+ end
@@ -4,13 +4,14 @@ module LocalVault
4
4
  class CLI
5
5
  class Team < Thor
6
6
  desc "list [VAULT]", "Show who has access to a vault"
7
+ method_option :vault, type: :string, aliases: "-v"
7
8
  def list(vault_name = nil)
8
9
  unless Config.token
9
10
  $stderr.puts "Error: Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
10
11
  return
11
12
  end
12
13
 
13
- vault_name ||= Config.default_vault
14
+ vault_name ||= options[:vault] || Config.default_vault
14
15
  client = ApiClient.new(token: Config.token)
15
16
  result = client.sent_shares(vault_name: vault_name)
16
17
  shares = (result["shares"] || []).reject { |s| s["status"] == "revoked" }
@@ -32,6 +33,34 @@ module LocalVault
32
33
  rescue ApiClient::ApiError => e
33
34
  $stderr.puts "Error: #{e.message}"
34
35
  end
36
+
37
+ desc "remove HANDLE", "Remove a person's access to a vault"
38
+ method_option :vault, type: :string, aliases: "-v"
39
+ def remove(handle)
40
+ unless Config.token
41
+ $stderr.puts "Error: Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
42
+ return
43
+ end
44
+
45
+ handle = handle.delete_prefix("@")
46
+ vault_name = options[:vault] || Config.default_vault
47
+ client = ApiClient.new(token: Config.token)
48
+
49
+ result = client.sent_shares(vault_name: vault_name)
50
+ share = (result["shares"] || []).find do |s|
51
+ s["recipient_handle"] == handle && s["status"] != "revoked"
52
+ end
53
+
54
+ unless share
55
+ $stderr.puts "Error: No active share found for @#{handle}."
56
+ return
57
+ end
58
+
59
+ client.revoke_share(share["id"])
60
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
61
+ rescue ApiClient::ApiError => e
62
+ $stderr.puts "Error: #{e.message}"
63
+ end
35
64
  end
36
65
  end
37
66
  end
@@ -421,13 +421,95 @@ module LocalVault
421
421
  $stdout.puts " localvault exec -- env | grep -E 'DATABASE|REDIS'"
422
422
  end
423
423
 
424
- # ── Teams / sharing ──────────────────────────────────────────────
424
+ # ── Teams / sharing / sync ────────────────────────────────────
425
425
 
426
426
  require_relative "cli/keys"
427
427
  require_relative "cli/team"
428
+ require_relative "cli/sync"
428
429
 
429
430
  register(Keys, "keys", "keys SUBCOMMAND", "Manage your X25519 keypair for vault sharing")
430
431
  register(Team, "team", "team SUBCOMMAND", "Manage vault team access")
432
+ register(Sync, "sync", "sync SUBCOMMAND", "Sync vaults to InventList cloud")
433
+
434
+ desc "keygen", "Generate your identity keypair for vault sync"
435
+ method_option :force, type: :boolean, default: false, desc: "Overwrite existing keypair"
436
+ method_option :show, type: :boolean, default: false, desc: "Print your existing public key"
437
+ def keygen
438
+ if options[:show]
439
+ unless Identity.exists?
440
+ $stdout.puts "No keypair found. Run: localvault keygen"
441
+ return
442
+ end
443
+ $stdout.puts Identity.public_key
444
+ return
445
+ end
446
+
447
+ if Identity.exists? && !options[:force]
448
+ $stdout.puts "Keypair already exists. Use --force to regenerate."
449
+ return
450
+ end
451
+
452
+ Config.ensure_directories!
453
+ Identity.generate!(force: options[:force])
454
+ $stdout.puts "Keypair generated."
455
+ $stdout.puts "Public key: #{Identity.public_key}"
456
+ end
457
+
458
+ desc "login [TOKEN]", "Log in to InventList — validate token, auto-keygen, publish public key"
459
+ method_option :status, type: :boolean, default: false, desc: "Show current login status"
460
+ def login(token = nil)
461
+ if options[:status]
462
+ handle = Config.inventlist_handle
463
+ if handle
464
+ $stdout.puts "Logged in as @#{handle}"
465
+ else
466
+ $stdout.puts "Not logged in. Run: localvault login TOKEN"
467
+ end
468
+ return
469
+ end
470
+
471
+ unless token
472
+ $stdout.puts "Usage: localvault login TOKEN"
473
+ $stdout.puts "Get your token at: https://inventlist.com/settings"
474
+ return
475
+ end
476
+
477
+ client = ApiClient.new(token: token)
478
+ data = client.me
479
+ handle = data.dig("user", "handle")
480
+
481
+ Config.token = token
482
+ Config.inventlist_handle = handle
483
+
484
+ Config.ensure_directories!
485
+ Identity.generate! unless Identity.exists?
486
+
487
+ client.publish_public_key(Identity.public_key)
488
+
489
+ $stdout.puts "Logged in as @#{handle}"
490
+ $stdout.puts "Public key published to your InventList profile."
491
+ $stdout.puts
492
+ $stdout.puts "Next: localvault sync push # sync your vault to the cloud"
493
+ rescue ApiClient::ApiError => e
494
+ if e.status == 401
495
+ $stdout.puts "Invalid token. Check your token at: https://inventlist.com/settings"
496
+ else
497
+ $stdout.puts "Error connecting to InventList: #{e.message}"
498
+ end
499
+ end
500
+
501
+ desc "logout", "Log out of InventList"
502
+ def logout
503
+ unless Config.token
504
+ $stdout.puts "Not logged in."
505
+ return
506
+ end
507
+
508
+ handle = Config.inventlist_handle
509
+ Config.token = nil
510
+ Config.inventlist_handle = nil
511
+ $stdout.puts "Logged out#{" @#{handle}" if handle}."
512
+ end
431
513
 
432
514
  desc "connect", "Connect to InventList for vault sharing"
433
515
  method_option :token, required: true, type: :string, desc: "InventList API token"
@@ -0,0 +1,30 @@
1
+ require "json"
2
+ require "base64"
3
+ require "digest"
4
+
5
+ module LocalVault
6
+ module SyncBundle
7
+ VERSION = 1
8
+
9
+ # Pack a vault's meta.yml + secrets.enc into a single JSON blob.
10
+ # The secrets.enc is already encrypted — this bundle is opaque to the server.
11
+ def self.pack(store)
12
+ meta_content = File.read(store.meta_path)
13
+ secrets_content = store.read_encrypted || ""
14
+ JSON.generate(
15
+ "version" => VERSION,
16
+ "meta" => Base64.strict_encode64(meta_content),
17
+ "secrets" => Base64.strict_encode64(secrets_content)
18
+ )
19
+ end
20
+
21
+ # Unpack a blob back into {meta:, secrets:} strings.
22
+ def self.unpack(blob)
23
+ data = JSON.parse(blob)
24
+ {
25
+ meta: Base64.strict_decode64(data.fetch("meta")),
26
+ secrets: Base64.strict_decode64(data.fetch("secrets"))
27
+ }
28
+ end
29
+ end
30
+ end
@@ -1,3 +1,3 @@
1
1
  module LocalVault
2
- VERSION = "0.9.6"
2
+ VERSION = "0.9.8"
3
3
  end
data/lib/localvault.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "localvault/vault"
6
6
  require_relative "localvault/identity"
7
7
  require_relative "localvault/share_crypto"
8
8
  require_relative "localvault/api_client"
9
+ require_relative "localvault/sync_bundle"
9
10
 
10
11
  module LocalVault
11
12
  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: 0.9.6
4
+ version: 0.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -109,6 +109,7 @@ files:
109
109
  - lib/localvault/api_client.rb
110
110
  - lib/localvault/cli.rb
111
111
  - lib/localvault/cli/keys.rb
112
+ - lib/localvault/cli/sync.rb
112
113
  - lib/localvault/cli/team.rb
113
114
  - lib/localvault/config.rb
114
115
  - lib/localvault/crypto.rb
@@ -118,6 +119,7 @@ files:
118
119
  - lib/localvault/session_cache.rb
119
120
  - lib/localvault/share_crypto.rb
120
121
  - lib/localvault/store.rb
122
+ - lib/localvault/sync_bundle.rb
121
123
  - lib/localvault/vault.rb
122
124
  - lib/localvault/version.rb
123
125
  homepage: https://github.com/inventlist/localvault