maquina_credentials 0.1.0 → 0.2.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: 77da8a494d686b452f4ca9745d69aaa3ce3bff688c7010ef651ed078604e5495
4
- data.tar.gz: 88d37fea885f62326aa550411ab702856c98079170f2c62caa35aa53b426e77e
3
+ metadata.gz: 79292e220c6fc74153fc3bc34dd018f8426ea4a4a568cc0957ee4894daa092db
4
+ data.tar.gz: b830667d1faef3b732413ec3817b2c468416a552973b586bbd410d37faa38a38
5
5
  SHA512:
6
- metadata.gz: ca1c6f06ff9cca2c67bd5438e57397cf9a80cd6ad59da3668d0e8b3d4edac1c2a13ab7af8235c64634f55b353a53d1d7b129a17bb08ace881835ad876335dd08
7
- data.tar.gz: 5df16514d996d6fed68bb431c8c528c305eb1cedafa67dc47fb0298150b6b1e0559f2f015d065a6398345232a350cae4f41e8ab28a081dc4e41d71d49f18d6a4
6
+ metadata.gz: a095ae071c46db142a393e9a789c1cc1aeb164ee89d1e04fb7a99a6f0a1ef251684876b28927c5f86c6cc51eb9792309b9ee273bdba1e9996710ebab24347827
7
+ data.tar.gz: 1eebb268b9024ce29db8cb9a3dd1c0405654e32764d5775e76070be337c21f5f8cdb94374fed913aecf7afcbb3efb0e10e4c984af3630aec9b9b850806ce5a63
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-06-28
4
+
5
+ - `mcr write` now **merges** the YAML from stdin into the existing credentials
6
+ file instead of replacing the whole file. Existing keys are preserved, nested
7
+ hashes are deep-merged, and scalar values are overwritten.
8
+ - Added `mcr edit`, which decrypts the credentials into a temporary file, opens
9
+ it in `$EDITOR` (or `$VISUAL`), and re-encrypts on save. Editing replaces the
10
+ full document, so removing a key in the editor removes it from the file.
11
+ - Added `Maquina::Credentials.merge` and `Maquina::Credentials.read_all` to the
12
+ Ruby API.
13
+
3
14
  ## [0.1.0] - 2026-06-24
4
15
 
5
16
  - Initial release
data/README.md CHANGED
@@ -58,12 +58,23 @@ Read a value (dot-paths traverse nested keys):
58
58
  MAQUINA_MASTER_KEY=<key> mcr read database.password
59
59
  ```
60
60
 
61
- Write credentials from piped YAML:
61
+ Write credentials from piped YAML. `write` **merges** into the existing file,
62
+ so you can add or update keys without resending the whole document:
62
63
 
63
64
  ```sh
64
65
  MAQUINA_MASTER_KEY=<key> printf "database:\n password: prod-secret\n" | mcr write
66
+ MAQUINA_MASTER_KEY=<key> printf "gh_token: ghp_xxx\n" | mcr write # database.password is kept
65
67
  ```
66
68
 
69
+ Edit the full document interactively in `$EDITOR` (re-encrypted on save):
70
+
71
+ ```sh
72
+ EDITOR=vim MAQUINA_MASTER_KEY=<key> mcr edit
73
+ ```
74
+
75
+ Unlike `write`, `edit` replaces the file with exactly what you save, so deleting
76
+ a key in the editor removes it from the file.
77
+
67
78
  Use a specific encrypted file:
68
79
 
69
80
  ```sh
@@ -99,11 +110,17 @@ credentials = Maquina::Credentials.new
99
110
  credentials.read("anthropic_key")
100
111
  credentials.read("database.password") # dot-path traversal
101
112
 
102
- # Writing replaces the whole file from a full hash:
113
+ # write replaces the whole file from a full hash:
103
114
  Maquina::Credentials.write({
104
115
  anthropic_key: "sk-ant-...",
105
116
  database: {password: "prod-secret"}
106
117
  })
118
+
119
+ # merge deep-merges into whatever is already stored:
120
+ Maquina::Credentials.merge({database: {password: "rotated"}})
121
+
122
+ # read_all returns the full decrypted hash (string keys), or {} when absent:
123
+ Maquina::Credentials.read_all
107
124
  ```
108
125
 
109
126
  - Missing credential paths return an empty string (`""`), never `nil`.
@@ -2,20 +2,31 @@
2
2
 
3
3
  require "optparse"
4
4
  require "securerandom"
5
+ require "shellwords"
6
+ require "tempfile"
5
7
  require "yaml"
6
8
 
7
9
  module Maquina
8
10
  class Credentials
9
11
  class CLI
10
- def self.start(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
11
- new(argv, stdout: stdout, stderr: stderr, stdin: stdin).run
12
+ EDIT_TEMPLATE = <<~YAML
13
+ # Edit your credentials below as YAML, then save and close.
14
+ # Example:
15
+ # database:
16
+ # password: s3cret
17
+ # gh_token: ghp_xxx
18
+ YAML
19
+
20
+ def self.start(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin, editor: nil)
21
+ new(argv, stdout: stdout, stderr: stderr, stdin: stdin, editor: editor).run
12
22
  end
13
23
 
14
- def initialize(argv, stdout:, stderr:, stdin:)
24
+ def initialize(argv, stdout:, stderr:, stdin:, editor: nil)
15
25
  @argv = argv.dup
16
26
  @stdout = stdout
17
27
  @stderr = stderr
18
28
  @stdin = stdin
29
+ @editor = editor || method(:default_editor)
19
30
  @credentials_path = nil
20
31
  end
21
32
 
@@ -32,6 +43,8 @@ module Maquina
32
43
  read(@argv[1])
33
44
  when "write"
34
45
  write
46
+ when "edit"
47
+ edit
35
48
  else
36
49
  @stderr.puts parser
37
50
  command ? 1 : 0
@@ -61,12 +74,14 @@ module Maquina
61
74
  mcr generate
62
75
  mcr read KEY [--file PATH]
63
76
  mcr write [--file PATH] < credentials.yml
77
+ mcr edit [--file PATH]
64
78
 
65
79
  Commands:
66
80
  help Show this help text.
67
81
  generate Print a new random master key to stdout.
68
82
  read Print a credential value to stdout.
69
- write Encrypt YAML from stdin and write it to the credentials file.
83
+ write Merge YAML from stdin into the credentials file.
84
+ edit Open the decrypted credentials in $EDITOR, re-encrypt on save.
70
85
 
71
86
  File selection:
72
87
  Use --file PATH to read or write a specific file.
@@ -76,6 +91,7 @@ module Maquina
76
91
  mcr generate
77
92
  mcr read database.password
78
93
  mcr --file /tmp/credentials.yml.enc write < credentials.yml
94
+ mcr edit
79
95
  mcr help
80
96
  TEXT
81
97
 
@@ -114,9 +130,44 @@ module Maquina
114
130
  return 1
115
131
  end
116
132
 
117
- Credentials.write(hash, credentials_path: @credentials_path)
133
+ Credentials.merge(hash, credentials_path: @credentials_path)
118
134
  0
119
135
  end
136
+
137
+ def edit
138
+ current = Credentials.read_all(credentials_path: @credentials_path)
139
+
140
+ Tempfile.create(["mcr-credentials", ".yml"]) do |file|
141
+ file.chmod(0o600)
142
+ file.write(current.empty? ? EDIT_TEMPLATE : YAML.dump(current))
143
+ file.flush
144
+
145
+ return 1 unless @editor.call(file.path)
146
+
147
+ edited = File.read(file.path)
148
+ hash = YAML.safe_load(edited, permitted_classes: [], symbolize_names: false)
149
+
150
+ unless hash.is_a?(Hash)
151
+ @stderr.puts "Edited content must be a YAML hash; no changes saved"
152
+ return 1
153
+ end
154
+
155
+ Credentials.write(hash, credentials_path: @credentials_path)
156
+ end
157
+
158
+ 0
159
+ end
160
+
161
+ def default_editor(path)
162
+ command = ENV["EDITOR"] || ENV["VISUAL"]
163
+
164
+ if command.nil? || command.empty?
165
+ @stderr.puts "Set $EDITOR or $VISUAL to use mcr edit"
166
+ return false
167
+ end
168
+
169
+ system(*Shellwords.split(command), path)
170
+ end
120
171
  end
121
172
  end
122
173
  end
@@ -33,6 +33,25 @@ module Maquina
33
33
  File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
34
34
  end
35
35
 
36
+ def self.merge(hash, credentials_path: nil)
37
+ credentials_path = resolve_credentials_path(credentials_path)
38
+ existing = read_all(credentials_path: credentials_path)
39
+ write(deep_merge(existing, deep_stringify(hash)), credentials_path: credentials_path)
40
+ end
41
+
42
+ def self.read_all(credentials_path: nil)
43
+ credentials_path = resolve_credentials_path(credentials_path)
44
+ return {} unless File.exist?(credentials_path)
45
+
46
+ decrypted = decrypt(File.read(credentials_path), master_key)
47
+ loaded = YAML.safe_load(decrypted, permitted_classes: [], symbolize_names: false)
48
+ raise DecryptionFailed unless loaded.is_a?(Hash)
49
+
50
+ deep_stringify(loaded)
51
+ rescue Psych::Exception
52
+ raise DecryptionFailed
53
+ end
54
+
36
55
  def self.encrypt(payload, raw_key)
37
56
  cipher = OpenSSL::Cipher.new(CIPHER)
38
57
  cipher.encrypt
@@ -105,6 +124,16 @@ module Maquina
105
124
  File.join(Dir.pwd, DEFAULT_CREDENTIALS_PATH)
106
125
  end
107
126
 
127
+ def self.deep_merge(base, override)
128
+ base.merge(override) do |_key, base_value, override_value|
129
+ if base_value.is_a?(Hash) && override_value.is_a?(Hash)
130
+ deep_merge(base_value, override_value)
131
+ else
132
+ override_value
133
+ end
134
+ end
135
+ end
136
+
108
137
  def self.deep_stringify(obj)
109
138
  case obj
110
139
  when Hash
@@ -147,15 +176,7 @@ module Maquina
147
176
  end
148
177
 
149
178
  def load_credentials
150
- return {} unless File.exist?(credentials_path)
151
-
152
- decrypted = self.class.decrypt(File.read(credentials_path), self.class.master_key)
153
- loaded = YAML.safe_load(decrypted, permitted_classes: [], symbolize_names: false)
154
- raise DecryptionFailed unless loaded.is_a?(Hash)
155
-
156
- self.class.deep_stringify(loaded)
157
- rescue Psych::Exception
158
- raise DecryptionFailed
179
+ self.class.read_all(credentials_path: credentials_path)
159
180
  end
160
181
  end
161
182
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Maquina
4
4
  class Credentials
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maquina_credentials
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez Cárdenas