mellon 1.0.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/.gitignore +23 -0
- data/.rspec +1 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +79 -0
- data/Rakefile +10 -0
- data/bin/mellon +106 -0
- data/elabs-logo.png +0 -0
- data/lib/mellon/keychain.rb +239 -0
- data/lib/mellon/shell_utils.rb +36 -0
- data/lib/mellon/store.rb +78 -0
- data/lib/mellon/version.rb +3 -0
- data/lib/mellon.rb +11 -0
- data/mellon.gemspec +27 -0
- data/spec/keychain.keychain +0 -0
- data/spec/mellon/keychain_spec.rb +88 -0
- data/spec/mellon/store_spec.rb +77 -0
- data/spec/mellon_spec.rb +5 -0
- data/spec/spec_helper.rb +18 -0
- metadata +154 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3d709112b62b9ed79d5d650602042faf2a3dfc2f
|
4
|
+
data.tar.gz: deda5ba7ba3a00ea77b3f914fd23c8d14cd7c04a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ede7a998d84a0504c55bcf1f203c832a8c800e647f43ff4c17a7edfb1801ee2e37292f5a771e04d97798985af4c2e0c32d9507dc7c5428d73acc6ed6ea38a45d
|
7
|
+
data.tar.gz: 19fef434f6cdd5a71599f9774a4d56e284ae82d51d819b2a001de0626da9d237ada8eaef8e89e4569d3fb91877d732169c9cefe0429eea11ae3524794bdacb70
|
data/.gitignore
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
23
|
+
spec/.*
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require ./spec/spec_helper
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Kim Burgestrand
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
# Mellon
|
2
|
+
|
3
|
+
> Speak, Friend, and enter.
|
4
|
+
|
5
|
+
Mellon is four things:
|
6
|
+
|
7
|
+
- A simple library for reading and writing notes in Mac OSX keychains. (see [Mellon::Keychain][])
|
8
|
+
- A simple library for using Mac OSX keychain notes as hash storage. (see [Mellon::Store][])
|
9
|
+
- A tiny CLI interface for [Mellon::Keychain][]. (see [Command-line interface](#command-line-interface))
|
10
|
+
|
11
|
+
I built Mellon because I wanted Yet Another Way of Managing Application Secrets™. Frankly, I am fed up
|
12
|
+
with passing around some secret file not included in our source code repository that runs out of sync
|
13
|
+
every time we add or change values during development.
|
14
|
+
|
15
|
+
Mellon, together with Econfig, solves this problem.
|
16
|
+
|
17
|
+
Mellon is sponsored by [Elabs][].
|
18
|
+
|
19
|
+
[![elabs logo][]][Elabs]
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
Mellon is intended for usage during development only. The recommended workflow is:
|
24
|
+
|
25
|
+
1. Create an OSX keychain using Keychain Access, name it something hip and clever, like `thanks-a-latte`.
|
26
|
+
2. Share your keychain with your colleagues, through iCloud sync, Dropbox or similar.
|
27
|
+
3. Hook up Mellon with your application according to instructions below.
|
28
|
+
|
29
|
+
### Using with Rails and Econfig (recommended)
|
30
|
+
|
31
|
+
See instructions over at [econfig-keychain](https://github.com/elabs/econfig-keychain).
|
32
|
+
|
33
|
+
# Documentation
|
34
|
+
|
35
|
+
An API reference can be found at [rdoc.info/github/elabs/mellon](http://rdoc.info/github/elabs/mellon/master/frames).
|
36
|
+
|
37
|
+
## Mellon::Keychain
|
38
|
+
|
39
|
+
Mellon::Keychain allows you to read and write notes in OSX keychains.
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
keychain = Mellon::Keychain.default
|
43
|
+
keychain["ruby note"] # => nil
|
44
|
+
keychain["ruby note"] = "Hello from Ruby!" # creates keychain note `ruby note`
|
45
|
+
keychain["ruby note"] # => "Hello from Ruby!"
|
46
|
+
keychain["ruby note"] = nil # deletes keychain note `ruby note`
|
47
|
+
```
|
48
|
+
|
49
|
+
More documentation can be found at the [API reference for Mellon::Keychain](http://rdoc.info/github/elabs/mellon/master/Mellon/Keychain).
|
50
|
+
|
51
|
+
## Mellon::Store
|
52
|
+
|
53
|
+
Mellon::Store is a layer above Mellon::Keychain, allowing you to use a single keychain note as hash storage. Notes are serialized as YAML by default.
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
project_name = "ruby note"
|
57
|
+
store = Mellon::Store.new(project_name, keychain: Mellon::Keychain.default)
|
58
|
+
store["some key"] # => nil
|
59
|
+
store["some key"] = "Hello from Ruby!" # creates keychain note "ruby note", and puts value for "some key" in it
|
60
|
+
store["some key"] # => "Hello from Ruby!"
|
61
|
+
|
62
|
+
# Have a peek at the data, which is serialized as YAML
|
63
|
+
store.keychain[store.project_name] # => "---\nsome key: Hello from Ruby!\n"
|
64
|
+
```
|
65
|
+
|
66
|
+
More documentation can be found at the [API reference for Mellon::Store](http://rdoc.info/github/elabs/mellon/master/Mellon/Store).
|
67
|
+
|
68
|
+
## Command-line interface
|
69
|
+
|
70
|
+
When you install the Mellon gem you also get an executable called `mellon`. See `mellon -h` for usage information.
|
71
|
+
|
72
|
+
# License
|
73
|
+
|
74
|
+
[See LICENSE](./LICENSE).
|
75
|
+
|
76
|
+
[Elabs]: http://www.elabs.se/
|
77
|
+
[elabs logo]: ./elabs-logo.png?raw=true
|
78
|
+
[Mellon::Keychain]: #mellon-keychain
|
79
|
+
[Mellon::Store]: #mellon-store
|
data/Rakefile
ADDED
data/bin/mellon
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# encoding: utf-8
|
3
|
+
|
4
|
+
require "mellon"
|
5
|
+
require "slop"
|
6
|
+
|
7
|
+
# Options
|
8
|
+
$keychain = nil
|
9
|
+
|
10
|
+
def find_closest(*filenames)
|
11
|
+
require "pathname"
|
12
|
+
Pathname.pwd.ascend do |parent|
|
13
|
+
filenames.each do |filename|
|
14
|
+
file = parent + filename
|
15
|
+
return parent.to_s if file.exist?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
yield
|
20
|
+
end
|
21
|
+
|
22
|
+
$default_name = File.basename(find_closest("Gemfile", "Rakefile") { Dir.pwd })
|
23
|
+
|
24
|
+
# Common procs
|
25
|
+
define_common = lambda do |dsl|
|
26
|
+
dsl.on :k, :keychain=, "Specify keychain to use" do |keychain_name|
|
27
|
+
keychain_path = File.expand_path(keychain_name)
|
28
|
+
|
29
|
+
$keychain = if File.exists?(keychain_path)
|
30
|
+
Mellon::Keychain.new(keychain_path)
|
31
|
+
else
|
32
|
+
Mellon::Keychain.find(keychain_name)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Convenience
|
38
|
+
def name_and_keychain(argv)
|
39
|
+
name = argv.join(" ")
|
40
|
+
name = $default_name if name.empty?
|
41
|
+
$keychain ||= Mellon::Keychain.search(name)
|
42
|
+
|
43
|
+
unless $keychain
|
44
|
+
$stderr.puts "key not found: #{name}"
|
45
|
+
yield if block_given?
|
46
|
+
exit false
|
47
|
+
end
|
48
|
+
|
49
|
+
[name, $keychain]
|
50
|
+
end
|
51
|
+
|
52
|
+
Slop.parse(strict: true, help: true) do
|
53
|
+
on :v, :version, "Show Mellon version." do
|
54
|
+
puts "Mellon v#{Mellon::VERSION}"
|
55
|
+
exit
|
56
|
+
end
|
57
|
+
|
58
|
+
description "list globally known keychains."
|
59
|
+
command "list" do
|
60
|
+
run do
|
61
|
+
puts "Available keychains:"
|
62
|
+
Mellon::Keychain.list.each do |keychain|
|
63
|
+
puts " #{keychain.name}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
description "edit or create a keychain entry."
|
69
|
+
command "edit" do
|
70
|
+
banner "Usage: mellon edit [options] [name (default: #{$default_name})]"
|
71
|
+
define_common[self]
|
72
|
+
|
73
|
+
run do |opts, argv|
|
74
|
+
name, keychain = name_and_keychain(argv) do
|
75
|
+
$stderr.puts "If you want to create it, you need to specify keychain with -k."
|
76
|
+
end
|
77
|
+
|
78
|
+
editor = ENV.fetch("EDITOR") do
|
79
|
+
$stderr.puts "$EDITOR is not set. Please set it to your preferred editor."
|
80
|
+
exit false
|
81
|
+
end
|
82
|
+
|
83
|
+
require "tempfile"
|
84
|
+
Tempfile.open([name, ".txt"]) do |io|
|
85
|
+
File.write io.path, keychain[name]
|
86
|
+
system editor, io.path
|
87
|
+
keychain[name] = File.read(io.path)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
description "show a keychain entry."
|
93
|
+
command "show" do
|
94
|
+
banner "Usage: mellon show [options] [name (default: #{$default_name})]"
|
95
|
+
define_common[self]
|
96
|
+
|
97
|
+
run do |opts, argv|
|
98
|
+
name, keychain = name_and_keychain(argv)
|
99
|
+
print keychain[name]
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
run do |opts, argv|
|
104
|
+
puts opts
|
105
|
+
end
|
106
|
+
end
|
data/elabs-logo.png
ADDED
Binary file
|
@@ -0,0 +1,239 @@
|
|
1
|
+
require "plist"
|
2
|
+
|
3
|
+
module Mellon
|
4
|
+
# Keychain provides simple methods for reading and storing keychain entries.
|
5
|
+
class Keychain
|
6
|
+
DEFAULT_OPTIONS = { type: :note }
|
7
|
+
TYPES = {
|
8
|
+
"note" => {
|
9
|
+
kind: "secure note",
|
10
|
+
type: "note"
|
11
|
+
}
|
12
|
+
}
|
13
|
+
|
14
|
+
class << self
|
15
|
+
# Find the first keychain that contains the key.
|
16
|
+
#
|
17
|
+
# @param [String] key
|
18
|
+
# @return [Keychain, nil]
|
19
|
+
def search(key)
|
20
|
+
output = ShellUtils.security("find-generic-password", "-l", key)
|
21
|
+
new(output[/keychain: "(.+)"/i, 1], ensure_exists: false)
|
22
|
+
rescue CommandError
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
|
26
|
+
# Find a keychain matching the given name.
|
27
|
+
#
|
28
|
+
# @param [String] name
|
29
|
+
# @return [Keychain]
|
30
|
+
# @raise [KeyError] if no matching keychain was found
|
31
|
+
def find(name)
|
32
|
+
quoted = Regexp.quote(name)
|
33
|
+
regexp = Regexp.new(quoted, Regexp::IGNORECASE)
|
34
|
+
|
35
|
+
keychain = list.find do |keychain|
|
36
|
+
keychain.path =~ regexp
|
37
|
+
end
|
38
|
+
|
39
|
+
if keychain.nil?
|
40
|
+
raise KeyError, "Could not find keychain “#{name}” in #{list.map(&:name).join(", ")}"
|
41
|
+
end
|
42
|
+
|
43
|
+
keychain
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [Keychain] default keychain
|
47
|
+
def default
|
48
|
+
keychain_path = ShellUtils.security("default-keychain")[KEYCHAIN_REGEXP, 1]
|
49
|
+
Keychain.new(keychain_path, ensure_exists: false)
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [Array<Keychain>] all available keychains
|
53
|
+
def list
|
54
|
+
ShellUtils.security("list-keychains").scan(KEYCHAIN_REGEXP).map do |(keychain_path)|
|
55
|
+
Keychain.new(keychain_path, ensure_exists: false)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Initialize a keychain on the given path.
|
61
|
+
#
|
62
|
+
# @param [String] path
|
63
|
+
# @param [Boolean] ensure_exists check if keychain exists or not
|
64
|
+
def initialize(path, ensure_exists: true)
|
65
|
+
@path = path
|
66
|
+
@name = File.basename(path, ".keychain")
|
67
|
+
command "show-keychain-info" if ensure_exists
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [String] path to keychain
|
71
|
+
attr_reader :path
|
72
|
+
|
73
|
+
# @return [String] keychain name (without extension)
|
74
|
+
attr_reader :name
|
75
|
+
|
76
|
+
# Retrieve a value, but if it does not exist return the default value,
|
77
|
+
# or call the provided block, or raise an error. See Hash#fetch.
|
78
|
+
#
|
79
|
+
# @param [String] key
|
80
|
+
# @param default
|
81
|
+
# @return [String] value for key, default, or value from block
|
82
|
+
# @yield if key does not exist, and block is given
|
83
|
+
# @raise [KeyError] if key does not exist, and no default is given
|
84
|
+
def fetch(key, *args, &block)
|
85
|
+
self[key] or {}.fetch(key, *args, &block)
|
86
|
+
end
|
87
|
+
|
88
|
+
# @param [String] key
|
89
|
+
# @return [String, nil] contents of entry at key, or nil if not set
|
90
|
+
def [](key)
|
91
|
+
_, data = read(key)
|
92
|
+
data
|
93
|
+
end
|
94
|
+
|
95
|
+
# Write data to entry key, or updating existing one if it exists.
|
96
|
+
#
|
97
|
+
# @param [String] key
|
98
|
+
# @param [String] data
|
99
|
+
def []=(key, data)
|
100
|
+
info, _ = read(key)
|
101
|
+
info ||= {}
|
102
|
+
|
103
|
+
if data
|
104
|
+
write(key, data, info)
|
105
|
+
else
|
106
|
+
delete(key, info)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
# Read a key from the keychain.
|
113
|
+
#
|
114
|
+
# @param [String] key
|
115
|
+
# @return [Array<Hash, String>, nil] tuple of entry info, and text contents, or nil if key does not exist
|
116
|
+
def read(key)
|
117
|
+
command "find-generic-password", "-g", "-l", key do |info, password_info|
|
118
|
+
[parse_info(info), parse_contents(password_info)]
|
119
|
+
end
|
120
|
+
rescue CommandError => e
|
121
|
+
nil
|
122
|
+
end
|
123
|
+
|
124
|
+
# Write data with given key to the keychain, or update existing key if it exists.
|
125
|
+
#
|
126
|
+
# @note keychain entries are not unique by key, but also by the information
|
127
|
+
# provided through options; two entries with same key but different
|
128
|
+
# account name (for example), will become two different entries when
|
129
|
+
# writing.
|
130
|
+
#
|
131
|
+
# @param [String] key
|
132
|
+
# @param [String] data
|
133
|
+
# @param [Hash] options
|
134
|
+
# @option options [#to_s] :type (:note) one of Keychain::TYPES
|
135
|
+
# @option options [String] :account_name ("")
|
136
|
+
# @option options [String] :service_name (key)
|
137
|
+
# @option options [String] :label (service_name)
|
138
|
+
# @raise [CommandError] if writing fails
|
139
|
+
def write(key, data, options = {})
|
140
|
+
info = build_info(key, options)
|
141
|
+
|
142
|
+
command "add-generic-password",
|
143
|
+
"-a", info[:account_name],
|
144
|
+
"-s", info[:service_name],
|
145
|
+
"-l", info[:label],
|
146
|
+
"-D", info[:kind],
|
147
|
+
"-C", info[:type],
|
148
|
+
"-T", "", # which applications have access (none)
|
149
|
+
"-U", # upsert
|
150
|
+
"-w", data
|
151
|
+
end
|
152
|
+
|
153
|
+
# Delete the entry matching key and options.
|
154
|
+
#
|
155
|
+
# @param [String] key
|
156
|
+
# @param [Hash] options
|
157
|
+
# @option (see #write)
|
158
|
+
def delete(key, options = {})
|
159
|
+
info = build_info(key, options)
|
160
|
+
|
161
|
+
command "delete-generic-password",
|
162
|
+
"-a", info[:account_name],
|
163
|
+
"-s", info[:service_name],
|
164
|
+
"-l", info[:label],
|
165
|
+
"-D", info[:kind],
|
166
|
+
"-C", info[:type]
|
167
|
+
end
|
168
|
+
|
169
|
+
# Execute a command with the context of this keychain.
|
170
|
+
#
|
171
|
+
# @param [Array<String>] command
|
172
|
+
def command(*command, &block)
|
173
|
+
command += [path]
|
174
|
+
ShellUtils.security *command, &block
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
# Build an info hash used for #write and #delete.
|
180
|
+
#
|
181
|
+
# @param [String] key
|
182
|
+
# @param [Hash] options
|
183
|
+
# @return [Hash]
|
184
|
+
def build_info(key, options = {})
|
185
|
+
options = DEFAULT_OPTIONS.merge(options)
|
186
|
+
|
187
|
+
note_type = TYPES.fetch(options.fetch(:type, :note).to_s)
|
188
|
+
account_name = options.fetch(:account_name, "")
|
189
|
+
service_name = options.fetch(:service_name, key)
|
190
|
+
label = options.fetch(:label, service_name)
|
191
|
+
|
192
|
+
{
|
193
|
+
account_name: account_name,
|
194
|
+
service_name: service_name,
|
195
|
+
label: label,
|
196
|
+
kind: note_type.fetch(:kind),
|
197
|
+
type: note_type.fetch(:type),
|
198
|
+
}
|
199
|
+
end
|
200
|
+
|
201
|
+
# Parse entry information.
|
202
|
+
#
|
203
|
+
# @param [String] info
|
204
|
+
# @return [Hash]
|
205
|
+
def parse_info(info)
|
206
|
+
extract = lambda { |key| info[/#{key}.+=(?:<NULL>|[^"]*"(.+)")/, 1].to_s }
|
207
|
+
{
|
208
|
+
account_name: extract["acct"],
|
209
|
+
kind: extract["desc"],
|
210
|
+
type: extract["type"],
|
211
|
+
label: extract["0x00000007"],
|
212
|
+
service_name: extract["svce"],
|
213
|
+
}
|
214
|
+
end
|
215
|
+
|
216
|
+
# Parse entry contents.
|
217
|
+
#
|
218
|
+
# @param [String]
|
219
|
+
# @return [String]
|
220
|
+
def parse_contents(password_info)
|
221
|
+
unpacked = password_info[/password: 0x([a-f0-9]+)/i, 1]
|
222
|
+
|
223
|
+
password = if unpacked
|
224
|
+
[unpacked].pack("H*")
|
225
|
+
else
|
226
|
+
password_info[/password: "(.+)"/m, 1]
|
227
|
+
end
|
228
|
+
|
229
|
+
password ||= ""
|
230
|
+
|
231
|
+
parsed = Plist.parse_xml(password.force_encoding("".encoding))
|
232
|
+
if parsed and parsed["NOTE"]
|
233
|
+
parsed["NOTE"]
|
234
|
+
else
|
235
|
+
password
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require "open3"
|
2
|
+
require "shellwords"
|
3
|
+
|
4
|
+
module Mellon
|
5
|
+
module ShellUtils
|
6
|
+
module_function
|
7
|
+
|
8
|
+
def security(*command, &block)
|
9
|
+
sh("security", *command, &block)
|
10
|
+
end
|
11
|
+
|
12
|
+
def sh(*command)
|
13
|
+
$stderr.puts command.join(" ") if $VERBOSE
|
14
|
+
stdout, stderr, status = Open3.capture3(*command)
|
15
|
+
|
16
|
+
stdout.chomp!
|
17
|
+
stderr.chomp!
|
18
|
+
|
19
|
+
unless status.success?
|
20
|
+
error_string = Shellwords.join(command)
|
21
|
+
error_string << "\n"
|
22
|
+
|
23
|
+
stderr = "<no output>" if stderr.empty?
|
24
|
+
error_string << " " << stderr.chomp
|
25
|
+
|
26
|
+
raise CommandError, "[ERROR] #{error_string}"
|
27
|
+
end
|
28
|
+
|
29
|
+
if block_given?
|
30
|
+
yield [stdout, stderr]
|
31
|
+
else
|
32
|
+
stdout
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/mellon/store.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
3
|
+
module Mellon
|
4
|
+
# Used for storing multiple values in a single Keychain entry.
|
5
|
+
#
|
6
|
+
# This is useful for configuring applications, e.g. having one entry per application,
|
7
|
+
# where each entry contains all configuration for said application.
|
8
|
+
class Store
|
9
|
+
attr_reader :project_name
|
10
|
+
attr_reader :keychain
|
11
|
+
attr_reader :serializer
|
12
|
+
|
13
|
+
# @example use keychain where entry exists, or default keychain
|
14
|
+
# Store.new("myapp")
|
15
|
+
#
|
16
|
+
# @example automatically find keychain
|
17
|
+
# Store.new("myapp", "shared_keychain")
|
18
|
+
#
|
19
|
+
# @example explicitly use keychain
|
20
|
+
# Store.new("myapp", Mellon::Keychain.new("/path/to/keychain.keychain"))
|
21
|
+
#
|
22
|
+
# @overload initialize(project_name)
|
23
|
+
# @overload initialize(project_name, keychain_name)
|
24
|
+
# @overload initialize(project_name, keychain)
|
25
|
+
#
|
26
|
+
# @param [String] project_name
|
27
|
+
# @param [String, Keychain, nil] keychain
|
28
|
+
# @param [#dump, #load] serializer
|
29
|
+
def initialize(project_name, keychain: Keychain.search(project_name), serializer: YAML)
|
30
|
+
@project_name = project_name.to_s
|
31
|
+
@keychain = if keychain.is_a?(Keychain)
|
32
|
+
keychain
|
33
|
+
elsif keychain.nil?
|
34
|
+
Keychain.default
|
35
|
+
else
|
36
|
+
Keychain.find(keychain.to_s)
|
37
|
+
end
|
38
|
+
@serializer = serializer
|
39
|
+
end
|
40
|
+
|
41
|
+
# @see Hash#fetch
|
42
|
+
def fetch(*args, &block)
|
43
|
+
data.fetch(*args, &block)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Retrieve a key from the store.
|
47
|
+
#
|
48
|
+
# @param [String] key
|
49
|
+
# @return [String, nil] value stored, or nil
|
50
|
+
def [](key)
|
51
|
+
data[key]
|
52
|
+
end
|
53
|
+
|
54
|
+
# Set a key in the store.
|
55
|
+
#
|
56
|
+
# @param [String] key
|
57
|
+
# @param [String] value
|
58
|
+
def []=(key, value)
|
59
|
+
dump data.merge(key => value)
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def data
|
65
|
+
config = @keychain[@project_name]
|
66
|
+
|
67
|
+
if config
|
68
|
+
@serializer.load(config)
|
69
|
+
else
|
70
|
+
{}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def dump(hash)
|
75
|
+
@keychain[@project_name] = @serializer.dump(hash)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
data/lib/mellon.rb
ADDED
data/mellon.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "mellon/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "mellon"
|
8
|
+
spec.version = Mellon::VERSION
|
9
|
+
spec.authors = ["Kim Burgestrand"]
|
10
|
+
spec.email = ["kim@burgestrand.se"]
|
11
|
+
spec.summary = %q{A command-line utility for managing secret application credentials via OSX keychain.}
|
12
|
+
spec.homepage = "https://github.com/elabs/mellon"
|
13
|
+
spec.license = "MIT"
|
14
|
+
|
15
|
+
spec.files = `git ls-files -z`.split("\x0")
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ["lib"]
|
19
|
+
|
20
|
+
spec.add_dependency "plist"
|
21
|
+
spec.add_dependency "slop"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "rspec"
|
26
|
+
spec.add_development_dependency "pry"
|
27
|
+
end
|
Binary file
|
@@ -0,0 +1,88 @@
|
|
1
|
+
describe Mellon::Keychain do
|
2
|
+
subject(:keychain) do
|
3
|
+
Mellon::Keychain.new(keychain_path)
|
4
|
+
end
|
5
|
+
|
6
|
+
specify "#name" do
|
7
|
+
keychain.name.should eq "temporary_keychain"
|
8
|
+
end
|
9
|
+
|
10
|
+
specify "#path" do
|
11
|
+
keychain.path.should eq keychain_path
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "#initialize" do
|
15
|
+
it "raises an error if keychain does not exist" do
|
16
|
+
expect { Mellon::Keychain.new("missing.keychain") }.to raise_error(Mellon::Error, /missing.keychain/)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "fetch" do
|
21
|
+
it "delegates (and as such, behaves equally) to #[]" do
|
22
|
+
keychain.should_receive(:[]).with("simple").and_call_original
|
23
|
+
keychain.fetch("simple").should eq "Simple note"
|
24
|
+
end
|
25
|
+
|
26
|
+
describe "behaves like Hash#fetch" do
|
27
|
+
specify "when key exists" do
|
28
|
+
keychain.fetch("simple", nil).should eq "Simple note"
|
29
|
+
keychain.fetch("simple", "default value").should eq "Simple note"
|
30
|
+
keychain.fetch("simple", "default value") { "block value" }.should eq "Simple note"
|
31
|
+
keychain.fetch("simple") { "block value" }.should eq "Simple note"
|
32
|
+
|
33
|
+
keychain.fetch("simple").should eq "Simple note"
|
34
|
+
end
|
35
|
+
|
36
|
+
specify "when key does not exist" do
|
37
|
+
keychain.fetch("missing", nil).should eq nil
|
38
|
+
keychain.fetch("missing", "default value").should eq "default value"
|
39
|
+
keychain.fetch("missing", "default value") { "block value" }.should eq "block value"
|
40
|
+
keychain.fetch("missing") { "block value" }.should eq "block value"
|
41
|
+
|
42
|
+
expect { keychain.fetch("missing") }.to raise_error(KeyError)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
describe "#[key]" do
|
48
|
+
it "reads simple entries" do
|
49
|
+
keychain["simple"].should eq "Simple note"
|
50
|
+
end
|
51
|
+
|
52
|
+
it "reads encoded entries" do
|
53
|
+
keychain["encoded"].should eq "Encoded\nnote"
|
54
|
+
end
|
55
|
+
|
56
|
+
it "reads plist entries" do
|
57
|
+
keychain["plist"].should eq "Plist note."
|
58
|
+
end
|
59
|
+
|
60
|
+
it "reads empty entries" do
|
61
|
+
keychain["empty"].should eq ""
|
62
|
+
end
|
63
|
+
|
64
|
+
it "returns nil when there is no entry with the given name" do
|
65
|
+
keychain["nonexisting note"].should be_nil
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
describe "#[]=" do
|
70
|
+
it "can create a new note" do
|
71
|
+
keychain["new note"].should be_nil
|
72
|
+
keychain["new note"] = "This is new data"
|
73
|
+
keychain["new note"].should eq "This is new data"
|
74
|
+
end
|
75
|
+
|
76
|
+
it "can write data to an existing note" do
|
77
|
+
keychain["existing"].should eq "Existing note."
|
78
|
+
keychain["existing"] = "This is new"
|
79
|
+
keychain["existing"].should eq "This is new"
|
80
|
+
end
|
81
|
+
|
82
|
+
it "can delete an existing note" do
|
83
|
+
keychain["doomed"].should_not be_nil
|
84
|
+
keychain["doomed"] = nil
|
85
|
+
keychain["doomed"].should be_nil
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
describe Mellon::Store do
|
2
|
+
subject(:store) { Mellon::Store.new(project_name, keychain: keychain) }
|
3
|
+
let(:project_name) { "yaml store" }
|
4
|
+
let(:keychain) { Mellon::Keychain.new(keychain_path) }
|
5
|
+
|
6
|
+
describe "#initialize" do
|
7
|
+
it "requires a project name" do
|
8
|
+
expect { Mellon::Store.new }.to raise_error(ArgumentError)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "finds and uses the keychain containing the project name by default" do
|
12
|
+
Mellon::Keychain.should_receive(:search).with(project_name).and_return(keychain)
|
13
|
+
Mellon::Store.new(project_name).keychain.should eq keychain
|
14
|
+
end
|
15
|
+
|
16
|
+
it "uses the default keychain is no keychain contains the project name" do
|
17
|
+
keychain = double
|
18
|
+
Mellon::Keychain.should_receive(:search).with(project_name).and_return(nil)
|
19
|
+
Mellon::Keychain.should_receive(:default).and_return(keychain)
|
20
|
+
Mellon::Store.new(project_name).keychain.should eq keychain
|
21
|
+
end
|
22
|
+
|
23
|
+
it "accepts specifying the keychain by name" do
|
24
|
+
keychain = double
|
25
|
+
Mellon::Keychain.should_receive(:find).with("projects").and_return(keychain)
|
26
|
+
store = Mellon::Store.new(project_name, keychain: "projects")
|
27
|
+
store.keychain.should eq keychain
|
28
|
+
end
|
29
|
+
|
30
|
+
it "accepts specifying the keychain object" do
|
31
|
+
store = Mellon::Store.new(project_name, keychain: keychain)
|
32
|
+
store.keychain.should eq keychain
|
33
|
+
end
|
34
|
+
|
35
|
+
it "allows setting the serializer" do
|
36
|
+
require "json"
|
37
|
+
store = Mellon::Store.new("json store", keychain: keychain, serializer: JSON)
|
38
|
+
store["some value"].should eq "This is some json value"
|
39
|
+
store["some value"] = "New value"
|
40
|
+
store["some value"].should eq "New value"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe "#[]" do
|
45
|
+
it "returns the value for key inside the store" do
|
46
|
+
store["some value"].should eq "This is some yaml value"
|
47
|
+
end
|
48
|
+
|
49
|
+
it "returns nil if store entry does not exist" do
|
50
|
+
store = Mellon::Store.new("missing project", keychain: keychain)
|
51
|
+
store["some value"].should be_nil
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe "#[]=" do
|
56
|
+
it "assigns an existing value for key inside the store" do
|
57
|
+
store["some value"].should eq "This is some yaml value"
|
58
|
+
store["some value"] = "This is a new value"
|
59
|
+
store["some value"].should eq "This is a new value"
|
60
|
+
end
|
61
|
+
|
62
|
+
it "creates the store entry if it does not exist" do
|
63
|
+
store = Mellon::Store.new("missing project", keychain: keychain)
|
64
|
+
store["some value"].should be_nil
|
65
|
+
store["some value"] = "That value"
|
66
|
+
store["some value"].should eq "That value"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
specify "#fetch" do
|
71
|
+
store.fetch("some value").should eq "This is some yaml value"
|
72
|
+
store.fetch("missing value", "default").should eq "default"
|
73
|
+
store.fetch("missing value") { "default" }.should eq "default"
|
74
|
+
|
75
|
+
expect { store.fetch("missing value") }.to raise_error(KeyError)
|
76
|
+
end
|
77
|
+
end
|
data/spec/mellon_spec.rb
ADDED
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require "mellon"
|
2
|
+
|
3
|
+
$stderr.puts "If asked for a password, just press enter. There is no password."
|
4
|
+
|
5
|
+
keychain_path = File.expand_path("./temporary_keychain.keychain", __dir__)
|
6
|
+
original_keychain_path = File.expand_path("./keychain.keychain", __dir__)
|
7
|
+
|
8
|
+
RSpec.configure do |config|
|
9
|
+
config.around do |example|
|
10
|
+
FileUtils.cp(original_keychain_path, keychain_path)
|
11
|
+
example.run
|
12
|
+
FileUtils.rm(keychain_path)
|
13
|
+
end
|
14
|
+
|
15
|
+
define_method :keychain_path do
|
16
|
+
keychain_path
|
17
|
+
end
|
18
|
+
end
|
metadata
ADDED
@@ -0,0 +1,154 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mellon
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kim Burgestrand
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-04-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: plist
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: slop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ~>
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.6'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.6'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - '>='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - '>='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description:
|
98
|
+
email:
|
99
|
+
- kim@burgestrand.se
|
100
|
+
executables:
|
101
|
+
- mellon
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files: []
|
104
|
+
files:
|
105
|
+
- .gitignore
|
106
|
+
- .rspec
|
107
|
+
- Gemfile
|
108
|
+
- LICENSE
|
109
|
+
- README.md
|
110
|
+
- Rakefile
|
111
|
+
- bin/mellon
|
112
|
+
- elabs-logo.png
|
113
|
+
- lib/mellon.rb
|
114
|
+
- lib/mellon/keychain.rb
|
115
|
+
- lib/mellon/shell_utils.rb
|
116
|
+
- lib/mellon/store.rb
|
117
|
+
- lib/mellon/version.rb
|
118
|
+
- mellon.gemspec
|
119
|
+
- spec/keychain.keychain
|
120
|
+
- spec/mellon/keychain_spec.rb
|
121
|
+
- spec/mellon/store_spec.rb
|
122
|
+
- spec/mellon_spec.rb
|
123
|
+
- spec/spec_helper.rb
|
124
|
+
homepage: https://github.com/elabs/mellon
|
125
|
+
licenses:
|
126
|
+
- MIT
|
127
|
+
metadata: {}
|
128
|
+
post_install_message:
|
129
|
+
rdoc_options: []
|
130
|
+
require_paths:
|
131
|
+
- lib
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - '>='
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
138
|
+
requirements:
|
139
|
+
- - '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
requirements: []
|
143
|
+
rubyforge_project:
|
144
|
+
rubygems_version: 2.2.2
|
145
|
+
signing_key:
|
146
|
+
specification_version: 4
|
147
|
+
summary: A command-line utility for managing secret application credentials via OSX
|
148
|
+
keychain.
|
149
|
+
test_files:
|
150
|
+
- spec/keychain.keychain
|
151
|
+
- spec/mellon/keychain_spec.rb
|
152
|
+
- spec/mellon/store_spec.rb
|
153
|
+
- spec/mellon_spec.rb
|
154
|
+
- spec/spec_helper.rb
|