pgai 1.0.5 → 1.1.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: c81f465309361fb42b7e4aea543ede08db3cb5b837eda96f8ae4098e7473fa47
4
- data.tar.gz: 0cae5222ce64fbef6e8257a36ecc54bfb3904d14634cf61eb95dc4cdb66b0018
3
+ metadata.gz: decf99fdd8015349a1f8ee139ac98a2e1abcbf2ea8c34c286158c72bb4cb9832
4
+ data.tar.gz: 3a4e095f9edb679451766b4ce0cb7f33be94855f0ea680c93782b8c4668168ec
5
5
  SHA512:
6
- metadata.gz: a8ab5b17cd0c5db154b83a1ff07c37bbfdc69633b4dbd405354d8c95c72c9a0c71eafd854990bfaaccb02293893bd4c960a64c46c41b0b97a4044578cb61bdc8
7
- data.tar.gz: ec64d232240c2a94ca05d3b24c21de321812e9e3296597c93e722750b979c4c831a6a84e3c012da1501ffbaf6d5088a627d0e583ee6835f50b898d1fc0d70b3b
6
+ metadata.gz: e9148734a065833aa71abc9f51c477339d442e3669021fd97aed5bf83ea590a49be7a88a4270944540921b7be1d898c6b85bbc5feb3f44cbdda78c682e5a7572
7
+ data.tar.gz: aaa8d36528923b16bf13156be9f87056d102ae4616fd60f939bb8143363ea8b4f010646ab66abdedcb6db946a5faa309f802e7a7aa8990081d394fef1f97e0e5
data/README.md CHANGED
@@ -80,6 +80,15 @@ pgai use -o ci -v -- bin/rails c -e test
80
80
  - The environment ID serves as the proxy host and the database name can be configured per environment.
81
81
  - Environments can share the same remote port value.
82
82
 
83
+ ## Upgrading to 1.1.0 and above
84
+
85
+ pgai now integrates with 1password to encrypt the token and clone details. Run these commands to migrate to the encrypted store:
86
+
87
+ ```shell
88
+ pgai enc keygen
89
+ pgai enc migrate
90
+ ```
91
+
83
92
  ## Contributing
84
93
 
85
94
  Bug reports and pull requests are welcome on GitLab at https://gitlab.com/mbobin/pgai.
@@ -0,0 +1,53 @@
1
+ module Pgai::Cli
2
+ class Enc < Base
3
+ desc "keygen", "Generate a new encryption key and save it to 1Password"
4
+ def keygen
5
+ encryption_key = build_encryption_key
6
+ key = encryption_key.generate
7
+
8
+ say "Generated new encryption key", :green
9
+ say ""
10
+
11
+ reference = encryption_key.save(key)
12
+
13
+ say ""
14
+ say "✓ Key saved to 1Password", :green
15
+ say " Reference: #{reference}", :cyan
16
+ end
17
+
18
+ desc "migrate", "Migrate from unencrypted to encrypted store"
19
+ def migrate
20
+ migrator = build_migrator
21
+
22
+ unless migrator.migration_needed?
23
+ say "No migration needed. Already using encrypted store.", :green
24
+ return
25
+ end
26
+
27
+ say "Starting migration from unencrypted to encrypted store...", :cyan
28
+
29
+ result = migrator.migrate
30
+
31
+ say ""
32
+ say "✓ Migration complete!", :green
33
+ say " Migrated #{result[:count]} record types", :cyan
34
+ say " Legacy store backed up to: #{result[:backup_path]}", :cyan
35
+ say " Remove it if the migration was successful", :cyan
36
+ end
37
+
38
+ private
39
+
40
+ def build_encryption_key
41
+ Pgai::Encryption::Key.new(
42
+ prompter: Pgai::Encryption::Prompter.new(shell: self)
43
+ )
44
+ end
45
+
46
+ def build_migrator
47
+ Pgai::Encryption::Migrator.new(
48
+ key: Pgai::Encryption::Key.new.read,
49
+ config_dir: Pgai.config_dir
50
+ )
51
+ end
52
+ end
53
+ end
data/lib/pgai/cli/main.rb CHANGED
@@ -42,6 +42,9 @@ module Pgai::Cli
42
42
  desc "env", "Manage environments"
43
43
  subcommand "env", Pgai::Cli::Env
44
44
 
45
+ desc "enc", "Manage encryption keys"
46
+ subcommand "enc", Pgai::Cli::Enc
47
+
45
48
  private
46
49
 
47
50
  def with_env(name, &block)
data/lib/pgai/client.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "excon"
4
-
5
3
  module Pgai
6
4
  class Client
7
5
  def initialize(token:, host: "http://127.0.0.1:2355")
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-logger"
4
- require "singleton"
5
- require "forwardable"
6
-
7
3
  module Pgai
8
4
  class Commander
9
5
  include Singleton
@@ -29,7 +25,7 @@ module Pgai
29
25
  end
30
26
 
31
27
  def store
32
- @store ||= Pgai::Store.new
28
+ @store ||= Pgai::Store.new(key: Pgai::Encryption::Key.new.read)
33
29
  end
34
30
 
35
31
  def config
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-progressbar"
4
-
5
3
  module Pgai
6
4
  class CreateCloneService
7
5
  HOSTNAME = "127.0.0.1"
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgai
4
+ module Encryption
5
+ class Key
6
+ ENV_VAR_NAME = "PGAI_MASTER_KEY"
7
+
8
+ def initialize(op_client: OnePasswordClient.new, reference_store: ReferenceStore.new, prompter: Prompter.new)
9
+ @op_client = op_client
10
+ @reference_store = reference_store
11
+ @prompter = prompter
12
+ end
13
+
14
+ def generate
15
+ Lockbox.generate_key
16
+ end
17
+
18
+ def save(key)
19
+ config = @prompter.prompt_for_config
20
+ reference = @op_client.create_item(key: key, **config)
21
+
22
+ @reference_store.write(reference)
23
+ reference
24
+ end
25
+
26
+ def read
27
+ ENV.fetch(ENV_VAR_NAME) { read_from_one_password }
28
+ end
29
+
30
+ private
31
+
32
+ def read_from_one_password
33
+ reference = @reference_store.read
34
+ return nil unless reference
35
+
36
+ @op_client.read_item(reference)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgai
4
+ module Encryption
5
+ class Migrator
6
+ def initialize(key:, config_dir: Pgai.config_dir)
7
+ @key = key
8
+ @config_dir = config_dir
9
+ end
10
+
11
+ def migration_needed?
12
+ legacy_store_path.exist? && !encrypted_store_path.exist?
13
+ end
14
+
15
+ def migrate
16
+ validate_prerequisites!
17
+
18
+ record_types = read_legacy_data
19
+ write_encrypted_data(record_types)
20
+ backup_legacy_store
21
+
22
+ {count: record_types.size, backup_path: backup_path}
23
+ end
24
+
25
+ private
26
+
27
+ def validate_prerequisites!
28
+ raise Pgai::CliError, "Encryption key not found. Run 'pgai enc keygen' first." unless @key
29
+ raise Pgai::CliError, "Legacy store not found at #{legacy_store_path}" unless legacy_store_path.exist?
30
+ raise Pgai::CliError, "Encrypted store already exists at #{encrypted_store_path}" if encrypted_store_path.exist?
31
+ end
32
+
33
+ def read_legacy_data
34
+ legacy_store = PStore.new(legacy_store_path)
35
+ record_types = {}
36
+
37
+ legacy_store.transaction(true) do
38
+ legacy_store.roots.each do |root|
39
+ record_types[root] = legacy_store[root]
40
+ end
41
+ end
42
+
43
+ record_types
44
+ end
45
+
46
+ def write_encrypted_data(record_types)
47
+ encrypted_store = Encryption::Store.new(encrypted_store_path, key: @key)
48
+
49
+ encrypted_store.transaction do
50
+ record_types.each do |type, data|
51
+ encrypted_store[type] = data
52
+ end
53
+ end
54
+ end
55
+
56
+ def backup_legacy_store
57
+ FileUtils.mv(legacy_store_path, backup_path)
58
+ end
59
+
60
+ def legacy_store_path
61
+ @legacy_store_path ||= @config_dir.join(Pgai::Store::LEGACY_STORE_NAME)
62
+ end
63
+
64
+ def encrypted_store_path
65
+ @encrypted_store_path ||= @config_dir.join(Pgai::Store::STORE_NAME)
66
+ end
67
+
68
+ def backup_path
69
+ @backup_path ||= @config_dir.join("#{Pgai::Store::LEGACY_STORE_NAME}.backup")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgai
4
+ module Encryption
5
+ class OnePasswordClient
6
+ class CommandError < StandardError; end
7
+
8
+ FIELD_NAME = "master_key"
9
+
10
+ def create_item(key:, vault:, title:, category:)
11
+ item_json = JSON.generate({
12
+ title: title,
13
+ fields: [
14
+ {
15
+ id: FIELD_NAME,
16
+ type: "CONCEALED",
17
+ label: FIELD_NAME,
18
+ value: key
19
+ }
20
+ ]
21
+ })
22
+
23
+ output = run_command(
24
+ "op", "item", "create",
25
+ "--vault", vault,
26
+ "--category", category,
27
+ "--format", "json",
28
+ "-",
29
+ stdin_data: item_json
30
+ )
31
+
32
+ build_reference_from(output)
33
+ end
34
+
35
+ def read_item(reference)
36
+ run_command("op", "read", reference).strip
37
+ end
38
+
39
+ private
40
+
41
+ def run_command(*args, stdin_data: "")
42
+ stdout, stderr, status = Open3.capture3(*args, stdin_data: stdin_data)
43
+
44
+ raise CommandError, "1Password CLI error: #{stderr}" unless status.success?
45
+
46
+ stdout
47
+ end
48
+
49
+ def build_reference_from(json_output)
50
+ item = JSON.parse(json_output)
51
+ vault_name = item.dig("vault", "name")
52
+ item_id = item.fetch("id")
53
+
54
+ "op://#{vault_name}/#{item_id}/#{FIELD_NAME}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgai
4
+ module Encryption
5
+ class Prompter
6
+ DEFAULTS = {
7
+ vault: "employee",
8
+ category: "API Credential",
9
+ title: "pgai master key"
10
+ }.freeze
11
+
12
+ def initialize(shell: Thor::Shell::Basic.new)
13
+ @shell = shell
14
+ end
15
+
16
+ def prompt_for_config
17
+ {
18
+ vault: ask_with_default("Vault", DEFAULTS[:vault]),
19
+ category: ask_with_default("Category", DEFAULTS[:category]),
20
+ title: ask_with_default("Title", DEFAULTS[:title])
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def ask_with_default(prompt, default)
27
+ response = @shell.ask("#{prompt} [#{default}]:")
28
+ response.to_s.strip.empty? ? default : response
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgai
4
+ module Encryption
5
+ class ReferenceStore
6
+ def initialize(path: nil)
7
+ path ||= Pgai.config_dir.join("op_ref")
8
+ @path = File.expand_path(path)
9
+ end
10
+
11
+ def write(reference)
12
+ ensure_directory_exists
13
+ File.write(@path, reference)
14
+ end
15
+
16
+ def read
17
+ return nil unless File.exist?(@path)
18
+
19
+ File.read(@path).strip
20
+ end
21
+
22
+ def exists?
23
+ File.exist?(@path) && !read.empty?
24
+ end
25
+
26
+ attr_reader :path
27
+
28
+ private
29
+
30
+ def ensure_directory_exists
31
+ FileUtils.mkdir_p(File.dirname(@path))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgai
4
+ module Encryption
5
+ class Store < PStore
6
+ def initialize(file_path, key:, thread_safe: false)
7
+ @lockbox = Lockbox.new(key: key, encode: true)
8
+ super(file_path, thread_safe)
9
+ end
10
+
11
+ private
12
+
13
+ def dump(table)
14
+ data = Marshal.dump(table)
15
+ @lockbox.encrypt(data)
16
+ end
17
+
18
+ def load(content)
19
+ content = content.read if content.is_a?(File)
20
+ decrypted = @lockbox.decrypt(content)
21
+
22
+ Marshal.load(decrypted)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "socket"
4
-
5
3
  module Pgai
6
4
  module Port
7
5
  class Allocator
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "socket"
4
- require "net/ssh"
5
-
6
3
  module Pgai
7
4
  module Port
8
5
  class Forwarder
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "concurrent-ruby"
4
-
5
3
  module Pgai
6
4
  module Port
7
5
  class Manager
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
-
5
3
  module Pgai
6
4
  module Resources
7
5
  module Attributes
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
-
5
3
  module Pgai
6
4
  module Resources
7
5
  module Local
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module Pgai
6
4
  module Resources
7
5
  module Remote
data/lib/pgai/store.rb CHANGED
@@ -1,49 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
- require "pstore"
5
- require "fileutils"
6
-
7
3
  module Pgai
8
4
  class Store
9
- STORE_PATH = "~/.config/pgai/config.pstore"
5
+ STORE_NAME = "config.enc.pstore"
6
+ LEGACY_STORE_NAME = "config.pstore"
7
+
8
+ def initialize(key: nil, config_dir: Pgai.config_dir)
9
+ @key = key
10
+ @config_dir = config_dir
11
+ end
10
12
 
11
13
  def all(record_type)
12
- store.transaction(true) do
13
- store[record_type]&.values || {}
14
+ backend.transaction(true) do
15
+ backend[record_type]&.values || {}
14
16
  end
15
17
  end
16
18
 
17
19
  def find(record_type, id)
18
- store.transaction(true) do
19
- (store[record_type] || {})[id]
20
+ backend.transaction(true) do
21
+ (backend[record_type] || {})[id]
20
22
  end
21
23
  end
22
24
 
23
25
  def delete(record_type, id)
24
- store.transaction do
25
- store[record_type] ||= {}
26
- store[record_type] = store[record_type].except(id)
26
+ backend.transaction do
27
+ backend[record_type] ||= {}
28
+ backend[record_type] = backend[record_type].except(id)
27
29
  end
28
30
  end
29
31
 
30
32
  def save(record_type, attributes, key: :id)
31
- store.transaction do
32
- store[record_type] ||= {}
33
- store[record_type].merge!(attributes[key] => attributes)
33
+ backend.transaction do
34
+ backend[record_type] ||= {}
35
+ backend[record_type].merge!(attributes[key] => attributes)
34
36
  end
35
37
  end
36
38
 
39
+ def encrypted?
40
+ backend.is_a?(Encryption::Store)
41
+ end
42
+
37
43
  private
38
44
 
39
- def store
40
- @store ||= PStore.new(store_path)
45
+ def backend
46
+ @backend ||= if pstore_path.exist? || !legacy_pstore_path.exist?
47
+ Encryption::Store.new(pstore_path, key: @key)
48
+ else
49
+ PStore.new(legacy_pstore_path)
50
+ end
41
51
  end
42
52
 
43
- def store_path
44
- @store_path ||= Pathname(STORE_PATH).expand_path.tap do |path|
45
- FileUtils.mkdir_p File.dirname(path)
46
- end
53
+ def pstore_path
54
+ @pstore_path ||= @config_dir.join(STORE_NAME)
55
+ end
56
+
57
+ def legacy_pstore_path
58
+ @legacy_pstore_path ||= @config_dir.join(LEGACY_STORE_NAME)
47
59
  end
48
60
  end
49
61
  end
data/lib/pgai/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Pgai
2
- VERSION = "1.0.5"
2
+ VERSION = "1.1.0"
3
3
  end
data/lib/pgai.rb CHANGED
@@ -1,12 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+ require "fileutils"
5
+ require "date"
6
+ require "securerandom"
7
+ require "forwardable"
8
+ require "singleton"
9
+ require "json"
10
+ require "pstore"
11
+ require "lockbox"
12
+ require "thor"
13
+ require "open3"
14
+ require "socket"
15
+ require "net/ssh"
16
+ require "concurrent-ruby"
17
+ require "excon"
18
+ require "tty-logger"
19
+ require "tty-progressbar"
3
20
  require "zeitwerk"
4
21
 
5
22
  loader = Zeitwerk::Loader.for_gem
6
23
  loader.setup
7
24
 
8
- require "thor"
9
-
10
25
  module Pgai
11
26
  class Error < StandardError; end
12
27
 
@@ -17,4 +32,14 @@ module Pgai
17
32
  class UnauthorizedError < CliError; end
18
33
 
19
34
  class BadRequestError < CliError; end
35
+
36
+ CONFIG_DIR = "~/.config/pgai/"
37
+
38
+ class << self
39
+ attr_writer :config_dir
40
+
41
+ def config_dir
42
+ @config_dir ||= Pathname(CONFIG_DIR).expand_path
43
+ end
44
+ end
20
45
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgai
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.5
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marius Bobin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-03 00:00:00.000000000 Z
11
+ date: 2026-01-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -172,6 +172,34 @@ dependencies:
172
172
  - - ">="
173
173
  - !ruby/object:Gem::Version
174
174
  version: 1.2.3
175
+ - !ruby/object:Gem::Dependency
176
+ name: lockbox
177
+ requirement: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - "~>"
180
+ - !ruby/object:Gem::Version
181
+ version: '2.1'
182
+ type: :runtime
183
+ prerelease: false
184
+ version_requirements: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - "~>"
187
+ - !ruby/object:Gem::Version
188
+ version: '2.1'
189
+ - !ruby/object:Gem::Dependency
190
+ name: base64
191
+ requirement: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - "~>"
194
+ - !ruby/object:Gem::Version
195
+ version: 0.3.0
196
+ type: :runtime
197
+ prerelease: false
198
+ version_requirements: !ruby/object:Gem::Requirement
199
+ requirements:
200
+ - - "~>"
201
+ - !ruby/object:Gem::Version
202
+ version: 0.3.0
175
203
  - !ruby/object:Gem::Dependency
176
204
  name: rake
177
205
  requirement: !ruby/object:Gem::Requirement
@@ -241,12 +269,19 @@ files:
241
269
  - bin/pgai
242
270
  - lib/pgai.rb
243
271
  - lib/pgai/cli/base.rb
272
+ - lib/pgai/cli/enc.rb
244
273
  - lib/pgai/cli/env.rb
245
274
  - lib/pgai/cli/main.rb
246
275
  - lib/pgai/client.rb
247
276
  - lib/pgai/clone_manager.rb
248
277
  - lib/pgai/commander.rb
249
278
  - lib/pgai/create_clone_service.rb
279
+ - lib/pgai/encryption/key.rb
280
+ - lib/pgai/encryption/migrator.rb
281
+ - lib/pgai/encryption/one_password_client.rb
282
+ - lib/pgai/encryption/prompter.rb
283
+ - lib/pgai/encryption/reference_store.rb
284
+ - lib/pgai/encryption/store.rb
250
285
  - lib/pgai/external_command_manager.rb
251
286
  - lib/pgai/port/allocator.rb
252
287
  - lib/pgai/port/forwarder.rb