rbop 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 96e8173b9f7f72075f24e5aab6ff1dd633f3db82ff6837c0b0e7564d7a9c1b34
4
+ data.tar.gz: d834308421132df4d396f5826a66c98596fee0ca64e90bd9e44882d1a4a1e617
5
+ SHA512:
6
+ metadata.gz: 56e254f1e31a0f5c43288ab80a959410a2a79fc516e66e28674476b2470831ac5eddafab139fb1d218e1da683414b37dc549a60e16b6095f7b6edb9905dc3405
7
+ data.tar.gz: 1ea5603a6234a9c458168d594a7752d6d719ae7718e62c62474b55ef2bd250a35e08d11b3fb1c9f417eff246c793776ec98076708555f8d23391242ea74bfacd
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Rbop
2
+
3
+ [![Ruby](https://github.com/timcase/rbop/actions/workflows/ruby.yml/badge.svg)](https://github.com/timcase/rbop/actions/workflows/ruby.yml)
4
+
5
+ A Ruby gem for seamless integration with the 1Password CLI (`op`). Rbop provides an intuitive, object-oriented interface for retrieving and working with 1Password items, complete with dynamic method access and intelligent type casting.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'rbop'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Or install it directly:
22
+
23
+ ```bash
24
+ gem install rbop
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ruby
30
+ require 'rbop'
31
+
32
+ # Initialize client with your 1Password account and vault
33
+ client = Rbop::Client.new(
34
+ account: "my-team.1password.com",
35
+ vault: "Personal"
36
+ )
37
+
38
+ # Retrieve an item by title
39
+ item = client.get(title: "GitHub Login")
40
+
41
+ # Access top-level properties
42
+ puts item.title # => "GitHub Login"
43
+ puts item.category # => "LOGIN"
44
+ puts item.created_at # => 2023-12-01 10:30:00 UTC (automatically parsed)
45
+
46
+ # Access field values dynamically
47
+ puts item.username # => { "label" => "username", "value" => "john.doe" }
48
+ puts item.password # => { "label" => "password", "value" => "secret123" }
49
+
50
+ # CamelCase fields are automatically converted to snake_case
51
+ puts item.two_factor_auth # => { "label" => "twoFactorAuth", "value" => "TOTP" }
52
+
53
+ # Collision handling with field_ prefix when needed
54
+ puts item.url # => "https://github.com" (top-level field)
55
+ puts item.field_url # => { "label" => "url", "value" => "backup-url" }
56
+
57
+ # Get raw hash data
58
+ puts item.to_h # => Deep copy of all item data
59
+ puts item.as_json # => Alias for to_h
60
+ ```
61
+
62
+ ## Features
63
+
64
+ ### 🔐 Session Management
65
+ - Automatic authentication with 1Password CLI
66
+ - Session token management and environment variable handling
67
+ - Intelligent sign-in detection and retry logic
68
+
69
+ ### 🎯 Flexible Item Selectors
70
+ - **By title**: `client.get(title: "My Login")`
71
+ - **By ID**: `client.get(id: "abc123def456")`
72
+ - **By share URL**: `client.get(url: "https://share.1password.com/s/...")`
73
+ - **By private URL**: `client.get(url: "https://my-team.1password.com/vaults/...")`
74
+
75
+ ### 🚀 Dynamic API Access
76
+ - **Method access**: Access any field as a method (`item.username`, `item.password`)
77
+ - **Bracket access**: String/symbol indifferent (`item["username"]`, `item[:username]`)
78
+ - **Collision handling**: Automatic `field_` prefix when field names conflict with Ruby methods
79
+ - **Enumeration**: Multiple fields with same name get numeric suffixes (`field_class_2`, `field_class_3`)
80
+ - **Case conversion**: CamelCase field labels become snake_case methods (`firstName` → `first_name`)
81
+
82
+ ### ⚡ Intelligent Type Casting
83
+ - **Timestamp parsing**: Automatic conversion of ISO-8601 strings to `Time` objects
84
+ - **Field-level casting**: Timestamps in field values are also converted
85
+ - **Graceful fallback**: Invalid timestamps return original string values
86
+
87
+ ### 🛡️ Data Safety
88
+ - **Deep copying**: `item.to_h` returns a deep copy to prevent mutation
89
+ - **Immutable access**: Original data remains unchanged regardless of modifications to copies
90
+ - **Type preservation**: Non-string values maintain their original types
91
+
92
+ ## Limitations
93
+
94
+ - **1Password CLI dependency**: Requires the official 1Password CLI (`op`) to be installed and available in PATH
95
+ - **Shell execution**: All operations execute shell commands under the hood
96
+ - **Thread safety**: ⚠️ **Not thread-safe** - session tokens are managed at the class level. Use separate client instances per thread or implement your own synchronization
97
+ - **Error handling**: Shell command failures bubble up as `Rbop::Shell::CommandFailed` exceptions
98
+ - **Authentication scope**: Supports only account-based authentication, not service account tokens
99
+
100
+ ## Requirements
101
+
102
+ - Ruby 3.1+
103
+ - [1Password CLI (op)](https://developer.1password.com/docs/cli/get-started/) v2.20.0+
104
+ - Valid 1Password account with CLI access
105
+
106
+ ## 1Password CLI Documentation
107
+
108
+ For more information about the underlying 1Password CLI:
109
+ - [Getting Started Guide](https://developer.1password.com/docs/cli/get-started/)
110
+ - [CLI Reference](https://developer.1password.com/docs/cli/reference/)
111
+ - [Authentication Methods](https://developer.1password.com/docs/cli/authentication/)
112
+
113
+ ## Development
114
+
115
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
116
+
117
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
118
+
119
+ ## Contributing
120
+
121
+ Bug reports and pull requests are welcome on GitHub at https://github.com/timcase/rbop.
122
+
123
+ ## License
124
+
125
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rbop
6
+ class Client
7
+ attr_reader :account, :vault
8
+
9
+ def initialize(account:, vault:)
10
+ @account = account
11
+ @vault = vault
12
+ ensure_cli_present
13
+ end
14
+
15
+ def get(title: nil, id: nil, url: nil, vault: nil)
16
+ # Build kwargs hash with only non-nil values
17
+ kwargs = {}
18
+ kwargs[:title] = title if title
19
+ kwargs[:id] = id if id
20
+ kwargs[:url] = url if url
21
+
22
+ selector = Rbop::Selector.parse(**kwargs)
23
+ args = build_op_args(selector, vault)
24
+ args += [ "--format", "json" ]
25
+ args += [ "--account", @account ]
26
+
27
+ cmd = [ "op" ] + args
28
+
29
+ begin
30
+ stdout, _status = Rbop.shell_runner.run(cmd)
31
+ rescue Rbop::Shell::CommandFailed
32
+ # If the command failed, assume it's an authentication error and try signing in
33
+ puts "[DEBUG] Command failed, attempting signin..." if Rbop.debug
34
+ signin!
35
+ stdout, _status = Rbop.shell_runner.run(cmd)
36
+ end
37
+
38
+ raw_hash = JSON.parse(stdout)
39
+ Rbop::Item.new(raw_hash)
40
+ rescue JSON::ParserError
41
+ raise JSON::ParserError, "Invalid JSON response from 1Password CLI"
42
+ end
43
+
44
+ def whoami?
45
+ cmd = "op whoami --format=json"
46
+ cmd += " --account #{@account}" if @account
47
+ stdout, _status = Rbop.shell_runner.run(cmd)
48
+
49
+ # Parse the response to ensure it's valid
50
+ data = JSON.parse(stdout)
51
+ !!(data["user_uuid"] && data["account_uuid"])
52
+ rescue Rbop::Shell::CommandFailed, JSON::ParserError
53
+ false
54
+ end
55
+
56
+
57
+ def signin!
58
+ # Get the session token
59
+ stdout, _status = Rbop.shell_runner.run("op signin --account #{@account} --raw")
60
+ session_token = stdout.strip
61
+
62
+ # Set the session token in the environment using the session token itself
63
+ # The op whoami command with the session token will tell us the user UUID
64
+ whoami_stdout, _ = Rbop.shell_runner.run("op whoami --format=json --account #{@account} --session #{session_token}")
65
+ whoami_data = JSON.parse(whoami_stdout)
66
+ user_uuid = whoami_data["user_uuid"]
67
+
68
+ # Set the session token in the correct environment variable
69
+ ENV["OP_SESSION_#{user_uuid}"] = session_token
70
+
71
+ true
72
+ rescue Rbop::Shell::CommandFailed, JSON::ParserError
73
+ raise RuntimeError, "1Password sign-in failed"
74
+ end
75
+
76
+ private
77
+
78
+ def ensure_signed_in
79
+ return if whoami?
80
+ signin!
81
+ end
82
+
83
+ def build_op_args(selector, vault_override = nil)
84
+ args = [ "item", "get" ]
85
+
86
+ case selector[:type]
87
+ when :title
88
+ args << selector[:value]
89
+ args << "--vault"
90
+ args << (vault_override || @vault)
91
+ when :id
92
+ args << "--id"
93
+ args << selector[:value]
94
+ when :url_share, :url_private
95
+ args << "--share-link"
96
+ args << selector[:value]
97
+ end
98
+
99
+ args
100
+ end
101
+
102
+ def ensure_cli_present
103
+ _stdout, _status = Rbop.shell_runner.run("op --version")
104
+ rescue Rbop::Shell::CommandFailed
105
+ raise RuntimeError, "1Password CLI (op) not found"
106
+ end
107
+ end
108
+ end
data/lib/rbop/item.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/inflector"
4
+ require "time"
5
+ require "set"
6
+
7
+ module Rbop
8
+ class Item
9
+ attr_reader :raw
10
+
11
+ # ISO-8601 datetime pattern
12
+ ISO_8601_REGEX = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
13
+
14
+ def initialize(raw_hash)
15
+ @raw = raw_hash
16
+ @data = deep_dup(raw_hash)
17
+ @memo = {}
18
+ @field_methods = {}
19
+ build_field_methods
20
+ end
21
+
22
+ def to_h
23
+ deep_dup(@data)
24
+ end
25
+
26
+ alias_method :as_json, :to_h
27
+
28
+ def [](key)
29
+ key_str = key.to_s
30
+ return @memo[:"[#{key_str}]"] if @memo.key?(:"[#{key_str}]")
31
+
32
+ value = @data[key_str]
33
+ @memo[:"[#{key_str}]"] = cast_value(key_str, value)
34
+ end
35
+
36
+ def method_missing(method_name, *args, &block)
37
+ if args.empty? && !block_given?
38
+ method_str = method_name.to_s
39
+
40
+ # Check for field methods first
41
+ if field_info = @field_methods[method_str]
42
+ field = field_info[:field]
43
+ # For field methods, we want to return just the value, not the entire field structure
44
+ if field.is_a?(Hash) && field.key?("value")
45
+ value = field["value"]
46
+ return @memo[method_name] ||= cast_value(field_info[:label], value)
47
+ else
48
+ return @memo[method_name] ||= field
49
+ end
50
+ end
51
+
52
+ # Check for top-level data keys
53
+ if @data.key?(method_str)
54
+ value = @data[method_str]
55
+ return @memo[method_name] ||= cast_value(method_str, value)
56
+ end
57
+ end
58
+
59
+ super
60
+ end
61
+
62
+ def respond_to_missing?(method_name, include_private = false)
63
+ @field_methods.key?(method_name.to_s) || @data.key?(method_name.to_s) || super
64
+ end
65
+
66
+ private
67
+
68
+ def cast_value(key, value)
69
+ return value unless value.is_a?(String)
70
+
71
+ # Cast if key ends with _at or value matches ISO-8601 pattern
72
+ if key.to_s.end_with?("_at") || value.match?(ISO_8601_REGEX)
73
+ begin
74
+ Time.parse(value)
75
+ rescue ArgumentError
76
+ value # Return original value if parsing fails
77
+ end
78
+ else
79
+ value
80
+ end
81
+ end
82
+
83
+ def build_field_methods
84
+ fields = @data["fields"]
85
+ return unless fields.is_a?(Array)
86
+
87
+ used_method_names = Set.new
88
+
89
+ fields.each do |field|
90
+ next unless field.is_a?(Hash) && field["label"]
91
+
92
+ label = field["label"]
93
+ base_method_name = ActiveSupport::Inflector.underscore(label.gsub(/\s+/, "_"))
94
+ method_name = base_method_name
95
+
96
+ # Check if method name is already used or has collision
97
+ if used_method_names.include?(method_name) || has_collision?(method_name)
98
+ method_name = "field_#{base_method_name}"
99
+
100
+ # Handle collision enumeration for field_ prefixed names
101
+ counter = 2
102
+ while used_method_names.include?(method_name)
103
+ method_name = "field_#{base_method_name}_#{counter}"
104
+ counter += 1
105
+ end
106
+ end
107
+
108
+ used_method_names.add(method_name)
109
+ @field_methods[method_name] = { field: field, label: label }
110
+ end
111
+ end
112
+
113
+ def has_collision?(method_name)
114
+ # Check if method exists in Ruby object hierarchy
115
+ respond_to?(method_name, true) ||
116
+ # Check if it collides with top-level data keys
117
+ @data.key?(method_name) ||
118
+ # Check if it's a dangerous Ruby method
119
+ %w[object_id class send].include?(method_name)
120
+ end
121
+
122
+ def deep_dup(obj)
123
+ case obj
124
+ when Hash
125
+ obj.transform_keys(&:to_s).transform_values { |v| deep_dup(v) }
126
+ when Array
127
+ obj.map { |v| deep_dup(v) }
128
+ else
129
+ obj.dup rescue obj
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbop
4
+ module Selector
5
+ class << self
6
+ def parse(**kwargs)
7
+ validate_arguments!(kwargs)
8
+
9
+ case kwargs.keys.first
10
+ when :title
11
+ { type: :title, value: kwargs[:title] }
12
+ when :id
13
+ { type: :id, value: kwargs[:id] }
14
+ when :url
15
+ parse_url(kwargs[:url])
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def validate_arguments!(kwargs)
22
+ if kwargs.empty?
23
+ raise ArgumentError, "Must provide one of: title:, id:, or url:"
24
+ end
25
+
26
+ if kwargs.keys.length > 1
27
+ raise ArgumentError, "Must provide exactly one of: title:, id:, or url:"
28
+ end
29
+
30
+ unless [ :title, :id, :url ].include?(kwargs.keys.first)
31
+ raise ArgumentError, "Must provide one of: title:, id:, or url:"
32
+ end
33
+ end
34
+
35
+ def parse_url(url)
36
+ if url.start_with?("https://share.1password.com/")
37
+ { type: :url_share, value: url }
38
+ elsif url.include?("/open/i?")
39
+ { type: :url_private, value: url }
40
+ else
41
+ raise ArgumentError, "URL must be a valid 1Password share URL or private link"
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/rbop/shell.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Rbop
6
+ # Shell runner for executing system commands
7
+ module Shell
8
+ # Exception raised when a command fails
9
+ class CommandFailed < RuntimeError
10
+ attr_reader :command, :status
11
+
12
+ def initialize(command, status)
13
+ @command = command
14
+ @status = status
15
+ super("Command failed with status #{status}: #{command}")
16
+ end
17
+ end
18
+
19
+ module_function
20
+
21
+ # Run a command and return stdout and exit status
22
+ #
23
+ # @param cmd [String, Array] Command to execute
24
+ # @param env [Hash] Environment variables to prepend
25
+ # @return [Array<String, Integer>] stdout and exit status
26
+ def run(cmd, env = {})
27
+ cmd_string = cmd.is_a?(Array) ? shell_escape_array(cmd) : cmd
28
+
29
+ # Log command execution if debug mode is enabled
30
+ if Rbop.debug
31
+ $stderr.puts "[RBOP DEBUG] Executing: #{cmd_string}"
32
+ $stderr.puts "[RBOP DEBUG] Environment: #{env.inspect}" unless env.empty?
33
+ end
34
+
35
+ # Merge env into current ENV for the command
36
+ if env.empty?
37
+ stdout = `#{cmd_string}`
38
+ else
39
+ # Use bash -c to ensure variable expansion works properly
40
+ env_string = env.map { |k, v| "#{k}='#{v}'" }.join(" ")
41
+ full_cmd = "#{env_string} bash -c '#{cmd_string}'"
42
+ $stderr.puts "[RBOP DEBUG] Full command: #{full_cmd}" if Rbop.debug
43
+ stdout = `#{full_cmd}`
44
+ end
45
+
46
+ status = $?.exitstatus
47
+
48
+ # Log results if debug mode is enabled
49
+ if Rbop.debug
50
+ $stderr.puts "[RBOP DEBUG] Exit status: #{status}"
51
+ if stdout.length > 200
52
+ $stderr.puts "[RBOP DEBUG] Output (truncated): #{stdout[0..200]}..."
53
+ else
54
+ $stderr.puts "[RBOP DEBUG] Output: #{stdout}"
55
+ end
56
+ end
57
+
58
+ if status != 0
59
+ # For authentication errors, include stderr/stdout in the exception
60
+ error_output = stdout.empty? ? "" : ": #{stdout}"
61
+ raise CommandFailed.new("#{cmd_string}#{error_output}", status)
62
+ end
63
+
64
+ [ stdout, status ]
65
+ end
66
+
67
+ def shell_escape_array(cmd_array)
68
+ Shellwords.join(cmd_array)
69
+ end
70
+
71
+ module_function :shell_escape_array
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rbop
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rbop.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbop/version"
4
+ require_relative "rbop/shell"
5
+ require_relative "rbop/client"
6
+ require_relative "rbop/selector"
7
+ require_relative "rbop/item"
8
+
9
+ module Rbop
10
+ class Error < StandardError; end
11
+
12
+ # Module attributes
13
+ class << self
14
+ attr_accessor :shell_runner
15
+ attr_accessor :debug
16
+ end
17
+
18
+ # Set defaults
19
+ self.shell_runner = Shell
20
+ self.debug = false
21
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rbop
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Case
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mocha
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: factory_bot
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: |-
69
+ rbop lets any Ruby ≥ 3.0 program pull secrets from 1Password with a single line of code.
70
+ It shells out to the official op CLI, performs an interactive sign-in when needed
71
+ email:
72
+ - tim@2drops.net
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - README.md
78
+ - lib/rbop.rb
79
+ - lib/rbop/client.rb
80
+ - lib/rbop/item.rb
81
+ - lib/rbop/selector.rb
82
+ - lib/rbop/shell.rb
83
+ - lib/rbop/version.rb
84
+ homepage: https://github.com/timcase/rbop
85
+ licenses:
86
+ - MIT
87
+ metadata: {}
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 3.3.0
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.6.9
103
+ specification_version: 4
104
+ summary: Ruby wrapper around the 1Password CLI for effortless secret retrieval in
105
+ your scripts.
106
+ test_files: []