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 +7 -0
- data/README.md +125 -0
- data/lib/rbop/client.rb +108 -0
- data/lib/rbop/item.rb +133 -0
- data/lib/rbop/selector.rb +46 -0
- data/lib/rbop/shell.rb +73 -0
- data/lib/rbop/version.rb +5 -0
- data/lib/rbop.rb +21 -0
- metadata +106 -0
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
|
+
[](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).
|
data/lib/rbop/client.rb
ADDED
|
@@ -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
|
data/lib/rbop/version.rb
ADDED
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: []
|