rubyshell 1.4.0 → 1.5.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: ef414945c6e9fc58ca5320103e4abf00d551737dffadd06e171e8ca37330752b
4
- data.tar.gz: d1784dc8f6ba9a21ea7e0da4fbde153b4f2f6d980037b572e02fe3c8b86a2e49
3
+ metadata.gz: 69051d4ef363ab387d6a7a92f4cdfa12ea51dd71983ebedf11583ca891ce00e4
4
+ data.tar.gz: 9bd6d572911e0c494833a8ab459b69c7648aacdaf18e13be95a03b7ff6270687
5
5
  SHA512:
6
- metadata.gz: a993d2b94f11e041a10dafb537a4bfb6395c3d06f81ed19da5cb3fb0b1a5e290d08fe310ee34592447aa90ce0910e714b35941c2bef78f1dbe0fa8abb7692348
7
- data.tar.gz: 146e1d266782ea61c73776799d7beadce9fcba0e1659edcf253c85e5c9ae57f143a39e27479d12394ee469bf589185a218ebb007b6b2fd7a6782606ec20a67c9
6
+ metadata.gz: 6d17ebb0eae8370dc331dc55cf45bb357d7f83841e2845db69af3da128ca128da3c284468d234230ab0faf4b374202cc374d45bf27adecf99dc79f547481c9b5
7
+ data.tar.gz: bfe7a64951d3fb6ddd5ca5988de9afcd90ef6cc5bf66fba063796c004103028daa20c33c373bb75de1b26d317702ff03771ff37499a5ef3c10100d5912bcbdcf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,116 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
1
5
  ## [Unreleased]
2
6
 
3
- ## [0.1.0] - 2025-09-10
7
+ ### Added
8
+
9
+ - **Parallel Execution** ([#34](https://github.com/albertalef/rubyshell/pull/34))
10
+ ```ruby
11
+ sh do
12
+ results = parallel do
13
+ curl("https://api1.example.com")
14
+ curl("https://api2.example.com")
15
+ chain { ls | wc("-l") }
16
+ end
17
+
18
+ results.each { |r| puts r }
19
+ end
20
+ ```
21
+ - Returns an Enumerator with results in completion order
22
+ - Supports regular commands, chains, and `sh()` calls
23
+ - Errors are captured and returned as values (not raised)
24
+
25
+ - **Chain Options** ([#34](https://github.com/albertalef/rubyshell/pull/34))
26
+ ```ruby
27
+ # Debug mode for chains
28
+ chain(debug: true) { ls | grep("test") }
29
+
30
+ # Parse chain output
31
+ chain(parse: :json) { curl("https://api.example.com") }
32
+ ```
33
+
34
+ - **Environment Variables** ([#47](https://github.com/albertalef/rubyshell/pull/47))
35
+ ```ruby
36
+ # Command-level
37
+ sh.npm("start", _env: { NODE_ENV: "production" })
38
+
39
+ # Block-level
40
+ sh(env: { DATABASE_URL: "postgres://localhost/db" }) do
41
+ rake("db:migrate")
42
+ end
43
+
44
+ # Global
45
+ RubyShell.env[:API_KEY] = "secret"
46
+ RubyShell.config(env: { DEBUG: "true" })
47
+ ```
48
+
49
+ - **Debug Mode** ([#39](https://github.com/albertalef/rubyshell/pull/39))
50
+ ```ruby
51
+ # Global
52
+ RubyShell.debug = true
53
+
54
+ # Block scope
55
+ RubyShell.debug { sh.ls }
56
+
57
+ # Per command
58
+ sh.git("status", _debug: true)
59
+ ```
60
+
61
+ - **Output Parsers** ([#38](https://github.com/albertalef/rubyshell/pull/38))
62
+ ```ruby
63
+ sh.cat("data.json", _parse: :json) # => Hash
64
+ sh.cat("config.yml", _parse: :yaml) # => Hash
65
+ sh.cat("users.csv", _parse: :csv) # => Array
66
+ ```
67
+
68
+ ### Changed
69
+
70
+ - **BREAKING:** Renamed `_env` to `env` in `RubyShell.config` and `sh` block options ([#34](https://github.com/albertalef/rubyshell/pull/34))
71
+ ```ruby
72
+ # Before
73
+ RubyShell.config(_env: { DEBUG: "true" })
74
+ sh(_env: { NODE_ENV: "production" }) { ... }
75
+
76
+ # After
77
+ RubyShell.config(env: { DEBUG: "true" })
78
+ sh(env: { NODE_ENV: "production" }) { ... }
79
+ ```
80
+ Note: Command-level `_env` option remains unchanged.
81
+
82
+ - Refactored specs directory structure
83
+
84
+
85
+ ## [1.4.0] - 2026-01-23
86
+
87
+ ### Added
88
+
89
+ - **Executable generator** ([#29](https://github.com/albertalef/rubyshell/pull/29))
90
+ ```bash
91
+ $ rubyshell new myscript
92
+ # Creates executable file with chmod +x
93
+ ```
94
+
95
+ - **Stdin parameter support**
96
+ - `_stdin` option to pass string or command output to stdin
97
+ ```ruby
98
+ xclip(_stdin: "text")
99
+ wc("-l", _stdin: some_command!)
100
+ ```
101
+
102
+ - **Array values in hash params**
103
+ ```ruby
104
+ sed(e: ["one", "two", "three"])
105
+ # equivalent to: sed -e 'one' -e 'two' -e 'three'
106
+ ```
107
+
108
+ - **Direct command execution for special syntax**
109
+ ```ruby
110
+ sh("notify-send", "hello")
111
+ sh("wl-copy", text)
112
+ ```
113
+
114
+ ## Previous
4
115
 
5
- - Initial release
116
+ - Not Tracked
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <img alt="RubyShell" src="./docs/images/rubyshelllogo.png" width="60%">
3
3
  </h1>
4
4
 
5
- <h3 align="center">✨ Rubist way to create shell scripts ✨</h3>
5
+ <h3 align="center">The Rubyist way to write shell scripts</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://rubygems.org/gems/rubyshell">
@@ -23,11 +23,11 @@
23
23
  ·
24
24
  <a href="#usage">Usage</a>
25
25
  ·
26
- <a href="#complete-example">Examples</a>
26
+ <a href="https://github.com/albertalef/rubyshell/wiki">Wiki</a>
27
27
  ·
28
- <a href="#contributing">Contributing</a>
28
+ <a href="#real-world-examples">Examples</a>
29
29
  ·
30
- <a href="#sponsors">Sponsors</a>
30
+ <a href="#contributing">Contributing</a>
31
31
  </p>
32
32
  </p>
33
33
  <br />
@@ -41,162 +41,412 @@
41
41
  end
42
42
  ```
43
43
 
44
- Yes, thats valid Ruby!
44
+ Yes, that's valid Ruby!
45
45
  `ls` and `cat` are just shell commands, but **RubyShell** makes them behave like Ruby methods.
46
46
 
47
47
  ## Installation
48
48
 
49
- Install the gem and add to the application's Gemfile by executing:
49
+ ```bash
50
+ bundle add rubyshell
51
+ ```
52
+
53
+ Or install directly:
54
+
55
+ ```bash
56
+ gem install rubyshell
57
+ ```
58
+
59
+ ## Why RubyShell?
60
+
61
+ ### The Problem
62
+
63
+ Ever written something like this?
64
+
65
+ ```bash
66
+ # Bash: Find large files modified in the last 7 days, show top 10 with human sizes
67
+ find . -type f -mtime -7 -exec ls -lh {} \; 2>/dev/null | \
68
+ awk '{print $5, $9}' | \
69
+ sort -hr | \
70
+ head -10
71
+ ```
72
+
73
+ Or tried to do error handling in bash?
50
74
 
51
- $ bundle add rubyshell
75
+ ```bash
76
+ # Bash: Hope nothing goes wrong...
77
+ output=$(some_command 2>&1) || echo "failed somehow"
78
+ ```
52
79
 
53
- If bundler is not being used to manage dependencies, install the gem by executing:
80
+ ### The Solution
54
81
 
55
- $ gem install rubyshell
82
+ ```ruby
83
+ sh do
84
+ # Ruby + Shell: Same task, actually readable
85
+ find(".", type: "f", mtime: "-7")
86
+ .lines
87
+ .map { |f| [File.size(f.strip), f.strip] }
88
+ .sort_by(&:first)
89
+ .last(10)
90
+ .each { |size, file| puts "#{size / 1024}KB #{file}" }
91
+ rescue RubyShell::CommandError => e
92
+ puts "Failed: #{e.message}"
93
+ puts "Exit code: #{e.status}"
94
+ end
95
+ ```
56
96
 
57
97
  ## Usage
58
98
 
59
- ### Calling a shell command
60
- With RubyShell, every shell command can be used inside the ruby, you just need to call
99
+ ### Basic Commands
61
100
 
62
101
  ```ruby
102
+ require 'rubyshell'
103
+
63
104
  sh do
64
- puts pwd # => /Users/albertalef/projects/rubyshell
105
+ pwd # Run any command
106
+ ls("-la") # With arguments
107
+ mkdir("project") # Create directories
108
+ docker("ps", all: true) # --all flag
109
+ git("status", s: true) # -s flag
65
110
  end
111
+
112
+ # Or chain directly
113
+ sh.git("log", oneline: true, n: 5)
66
114
  ```
67
115
 
68
- ### Using without the block
69
- If you want to start an irb or pry session, and run commands as first-class citizens then do the following.
116
+ ### Pipelines
70
117
 
71
118
  ```ruby
72
- extend RubyShell::Executor
119
+ sh do
120
+ # Using chain block
121
+ chain { cat("access.log") | grep("ERROR") | wc("-l") }
73
122
 
74
- pwd # => /Users/albertalef/projects/rubyshell
123
+ # Using bang pattern
124
+ (cat!("data.csv") | sort! | uniq!).exec
125
+ end
75
126
  ```
76
127
 
77
- ### Passing arguments
78
- Here we have different ways to pass arguments to a command.
79
- You can separate strings, use only one, use hashes, anyway will work
128
+ ### Directory Scoping
80
129
 
81
130
  ```ruby
82
131
  sh do
83
- docker("ps", all: true) # Using hash syntax = docker ps --all
132
+ cd "/var/log" do
133
+ # Commands run here, then return to original dir
134
+ tail("-n", "100", "syslog")
135
+ end
136
+ # Back to original directory
137
+ end
138
+ ```
84
139
 
85
- docker("ps", a: true) # Using hash syntax = docker ps -a
140
+ ### Error Handling
86
141
 
87
- docker("ps", '-a') # Passing multiple strings = docker ps -a
142
+ ```ruby
143
+ sh do
144
+ begin
145
+ rm("-rf", "important_folder")
146
+ rescue RubyShell::CommandError => e
147
+ puts "Command: #{e.command}"
148
+ puts "Stderr: #{e.stderr}"
149
+ puts "Exit code: #{e.status}"
150
+ end
151
+ end
152
+ ```
153
+
154
+ ### Parallel Execution
88
155
 
89
- docker("ps -a") # Passing one string = docker ps -a
156
+ Run multiple commands concurrently and get results as they complete:
157
+
158
+ ```ruby
159
+ sh do
160
+ results = parallel do
161
+ curl("https://api1.example.com")
162
+ curl("https://api2.example.com")
163
+ chain { ls | wc("-l") }
164
+ end
165
+
166
+ results.each { |r| puts r }
90
167
  end
91
168
  ```
92
169
 
93
- ### Changing folder
94
- Has two possible ways, changing the folder of the code, or running code only inside a folder
170
+ Returns an Enumerator with results in completion order. Errors are captured and returned as values (not raised).
171
+
172
+ ### Environment Variables
173
+
174
+ ```ruby
175
+ # Command-level
176
+ sh.npm("start", _env: { NODE_ENV: "production" })
177
+
178
+ # Block-level
179
+ sh(env: { DATABASE_URL: "postgres://localhost/db" }) do
180
+ rake("db:migrate")
181
+ end
182
+
183
+ # Global
184
+ RubyShell.env[:API_KEY] = "secret"
185
+ RubyShell.config(env: { DEBUG: "true" })
186
+ ```
187
+
188
+ ### Debug Mode
189
+
190
+ ```ruby
191
+ # Global
192
+ RubyShell.debug = true
193
+
194
+ # Block scope
195
+ RubyShell.debug { sh.ls }
196
+
197
+ # Per command
198
+ sh.git("status", _debug: true)
199
+ # Output:
200
+ # Executed: git status
201
+ # Duration: 0.003521s
202
+ # Pid: 12345
203
+ # Exit code: 0
204
+ # Stdout: "On branch main..."
205
+ ```
206
+
207
+ ### Output Parsers
208
+
209
+ Parse command output directly into Ruby objects:
210
+
211
+ ```ruby
212
+ sh.cat("data.json", _parse: :json) # => Hash
213
+ sh.cat("config.yml", _parse: :yaml) # => Hash
214
+ sh.cat("users.csv", _parse: :csv) # => Array
215
+ ```
216
+
217
+ ### Chain Options
218
+
219
+ ```ruby
220
+ # Debug mode for chains
221
+ chain(debug: true) { ls | grep("test") }
222
+
223
+ # Parse chain output
224
+ chain(parse: :json) { curl("https://api.example.com") }
225
+ ```
226
+
227
+ ## Real-World Examples
228
+
229
+ ### Git Workflow Automation
95
230
 
96
- ##### Chaging code folder
97
231
  ```ruby
98
232
  sh do
99
- puts pwd # => /Users/albertalef/projects/rubyshell
233
+ # Stash changes, pull, pop, and show what changed
234
+ changes = git("status", porcelain: true).lines
235
+
236
+ if changes.any?
237
+ puts "Stashing #{changes.count} changed files..."
238
+ git("stash")
239
+ git("pull", rebase: true)
240
+ git("stash", "pop")
241
+ else
242
+ git("pull", rebase: true)
243
+ end
100
244
 
101
- cd 'examples'
245
+ # Show recent commits by author
246
+ git("log", oneline: true, n: 100)
247
+ .lines
248
+ .map { |line| `git show -s --format='%an' #{line.split.first}`.strip }
249
+ .tally
250
+ .sort_by { |_, count| -count }
251
+ .first(5)
252
+ .each { |author, count| puts "#{author}: #{count} commits" }
253
+ end
254
+ ```
255
+
256
+ ### Log Analysis
102
257
 
103
- puts pwd # => /Users/albertalef/projects/rubyshell/examples
258
+ ```ruby
259
+ sh do
260
+ cd "/var/log" do
261
+ # Parse nginx logs: top 10 IPs by request count
262
+ cat("nginx/access.log")
263
+ .lines
264
+ .map { |line| line.split.first } # Extract IP
265
+ .tally
266
+ .sort_by { |_, count| -count }
267
+ .first(10)
268
+ .each { |ip, count| puts "#{ip.ljust(15)} #{count} requests" }
269
+ end
104
270
  end
105
271
  ```
106
272
 
107
- ##### Executing code inside another folder
273
+ ### Docker Cleanup
108
274
 
109
275
  ```ruby
110
276
  sh do
111
- cd 'examples' do
112
- puts pwd # => /Users/albertalef/projects/rubyshell/examples
277
+ # Remove containers that exited more than a day ago
278
+ containers = docker("ps", a: true, format: "{{.ID}} {{.Status}}")
279
+ .lines
280
+ .select { |line| line.include?("Exited") }
281
+ .map { |line| line.split.first }
282
+
283
+ if containers.any?
284
+ puts "Removing #{containers.count} dead containers..."
285
+ docker("rm", *containers)
113
286
  end
114
287
 
115
- puts pwd # => /Users/albertalef/projects/rubyshell
288
+ # Remove dangling images
289
+ images = docker("images", f: "dangling=true", q: true).lines.map(&:strip)
290
+
291
+ if images.any?
292
+ puts "Removing #{images.count} dangling images..."
293
+ docker("rmi", *images)
294
+ end
295
+
296
+ puts "Disk usage:"
297
+ puts docker("system", "df")
116
298
  end
117
299
  ```
118
300
 
119
- ### Chaining commands
120
- The `chain` method make possible we use shell operators inside the ruby, like `& && | > >> < <<`
301
+ ### Batch File Processing
121
302
 
122
303
  ```ruby
123
304
  sh do
124
- chain { echo "Dummy text" >> "dummy.txt" }
125
-
126
- puts cat("dummy.txt") # => "Dummy text"
305
+ # Convert all PNGs to WebP, preserving directory structure
306
+ find(".", name: "*.png")
307
+ .lines
308
+ .map(&:strip)
309
+ .each do |png|
310
+ webp = png.sub(/\.png$/, ".webp")
311
+ puts "Converting: #{png}"
312
+
313
+ begin
314
+ cwebp("-q", "80", png, o: webp)
315
+ rm(png)
316
+ rescue RubyShell::CommandError => e
317
+ puts " Failed: #{e.message}"
318
+ end
319
+ end
127
320
  end
321
+ ```
128
322
 
323
+ ### System Health Check
324
+
325
+ ```ruby
129
326
  sh do
130
- number_of_files = chain { ls | wc('-l') }.chomp
327
+ puts "=== System Health ==="
328
+
329
+ # Disk usage warnings
330
+ df("-h")
331
+ .lines
332
+ .drop(1)
333
+ .each do |line|
334
+ parts = line.split
335
+ usage = parts[4].to_i
336
+ mount = parts[5]
337
+ puts "WARNING: #{mount} at #{usage}%" if usage > 80
338
+ end
131
339
 
132
- puts number_of_files # => 5
340
+ # Memory info
341
+ mem = cat("/proc/meminfo")
342
+ .lines
343
+ .first(3)
344
+ .to_h { |l| k, v = l.split(":"); [k, v.strip] }
345
+
346
+ puts "\nMemory: #{mem['MemAvailable']} available of #{mem['MemTotal']}"
347
+
348
+ # Top 5 CPU consumers
349
+ puts "\nTop CPU processes:"
350
+ ps("aux", sort: "-%cpu")
351
+ .lines
352
+ .drop(1)
353
+ .first(5)
354
+ .each { |proc| puts " #{proc.split[10]}% - #{proc.split[10..-1].join(' ').slice(0, 40)}" }
133
355
  end
134
356
  ```
135
357
 
136
- ### Executing without a block
137
- The `sh` method can receive any method call, and execute shell commands
358
+ ### Interactive Script with Confirmation
138
359
 
139
360
  ```ruby
140
- sh.puts pwd # => /Users/albertalef/projects/rubyshell
361
+ sh do
362
+ files = find(".", name: "*.tmp", mtime: "+30").lines.map(&:strip)
363
+
364
+ if files.empty?
365
+ puts "No old temp files found."
366
+ exit
367
+ end
141
368
 
142
- sh.cd 'examples'
369
+ puts "Found #{files.count} temp files older than 30 days:"
370
+ files.first(10).each { |f| puts " #{f}" }
371
+ puts " ... and #{files.count - 10} more" if files.count > 10
143
372
 
144
- puts sh.pwd # => /Users/albertalef/projects/rubyshell/examples
373
+ total_size = files.sum { |f| File.size(f) rescue 0 }
374
+ puts "\nTotal size: #{total_size / 1024 / 1024}MB"
375
+
376
+ print "\nDelete all? [y/N] "
377
+ if gets.strip.downcase == 'y'
378
+ files.each { |f| rm(f) }
379
+ puts "Deleted #{files.count} files."
380
+ end
381
+ end
145
382
  ```
146
383
 
147
- ## Complete example
384
+ ### Deploy Script
148
385
 
149
386
  ```ruby
150
387
  #!/usr/bin/env ruby
388
+ require 'rubyshell'
151
389
 
152
- require "rubyshell"
153
- require "securerandom"
390
+ APP_NAME = "myapp"
391
+ DEPLOY_PATH = "/var/www/#{APP_NAME}"
154
392
 
155
393
  sh do
156
- mkdir "files"
394
+ puts "Deploying #{APP_NAME}..."
157
395
 
158
- cd "files" do
159
- 5.times do |i|
160
- chain do
161
- echo(SecureRandom.alphanumeric(16)) >> "#{i}.txt"
162
- end
163
- end
396
+ # Ensure clean state
397
+ git("status", porcelain: true).lines.tap do |changes|
398
+ abort "Uncommitted changes!" if changes.any?
399
+ end
164
400
 
165
- puts "Number of Files: #{ls.lines.count}"
401
+ # Run tests
402
+ puts "Running tests..."
403
+ rake("spec")
166
404
 
167
- ls.each_line do |filename|
168
- puts cat(filename)
169
- end
405
+ # Build and deploy
406
+ cd DEPLOY_PATH do
407
+ git("pull", "origin", "main")
408
+ bundle("install", deployment: true)
409
+ rake("db:migrate")
410
+
411
+ # Restart with zero downtime
412
+ puts "Restarting..."
413
+ systemctl("reload", APP_NAME)
170
414
  end
171
- ensure
172
- rm "-rf files"
173
- end
174
415
 
175
- # Running:
176
- #
177
- # ./examples/example1.rb
178
- #
179
- # Number of Files: 5
180
- # o6Kw8KHvWJnLGSeQ
181
- # qkRKcZHqu2Moq1se
182
- # nUPluln9GM1ydtoz
183
- # rkdYsc1RBhkeN1dq
184
- # ZPXZMqzYfyFfjPHF
416
+ puts "Deployed successfully!"
417
+
418
+ rescue RubyShell::CommandError => e
419
+ puts "Deploy failed: #{e.message}"
420
+ exit 1
421
+ end
185
422
  ```
186
423
 
187
- ## Coming
424
+ ## Comparison
188
425
 
189
- - Support to Streams
426
+ | Task | Bash | RubyShell |
427
+ |------|------|-----------|
428
+ | Error handling | `cmd \|\| echo "fail"` | `rescue CommandError` |
429
+ | String manipulation | `echo $var \| sed \| awk` | `result.gsub(/.../)` |
430
+ | Data structures | Arrays only | Hashes, objects, classes |
431
+ | Iteration | `for f in *; do` | `.each`, `.map`, `.select` |
432
+ | Testing | DIY | RSpec, Minitest |
190
433
 
191
- ## Development
434
+ ## Documentation
192
435
 
193
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
436
+ See [Wiki](https://github.com/albertalef/rubyshell/wiki) for complete documentation including all options and advanced features.
194
437
 
195
- 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).
438
+ ## Development
439
+
440
+ ```bash
441
+ bin/setup # Install dependencies
442
+ rake spec # Run tests
443
+ rake rubocop # Lint code
444
+ bin/console # Interactive console
445
+ ```
196
446
 
197
447
  ## Contributing
198
448
 
199
- Bug reports and pull requests are welcome on GitHub at https://github.com/albertalef/rubyshell. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/albertalef/rubyshell/blob/master/CODE_OF_CONDUCT.md).
449
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/albertalef/rubyshell).
200
450
 
201
451
  ## Sponsors
202
452
 
@@ -206,8 +456,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/albert
206
456
 
207
457
  ## License
208
458
 
209
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
210
-
211
- ## Code of Conduct
212
-
213
- Everyone interacting in the Rubysh project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/albertalef/rubyshell/blob/master/CODE_OF_CONDUCT.md).
459
+ MIT License - see [LICENSE](https://opensource.org/licenses/MIT).
@@ -2,15 +2,22 @@
2
2
 
3
3
  module RubyShell
4
4
  class ChainContext
5
- def self.sh(command, *args)
5
+ def initialize(options = {})
6
+ @options = options
7
+ end
8
+
9
+ def sh(command, *args)
6
10
  method_missing(command, *args)
7
11
  end
8
12
 
9
- def self.method_missing(method_name, *args, &block)
10
- RubyShell::Chainer.new(RubyShell::Command.new(method_name, *(args << { _manual: true }), &block))
13
+ def method_missing(method_name, *args, &block)
14
+ RubyShell::Chainer.new(
15
+ RubyShell::Command.new(method_name, *args, &block),
16
+ @options
17
+ )
11
18
  end
12
19
 
13
- def self.respond_to_missing?(_name, _include_private)
20
+ def respond_to_missing?(_name, _include_private)
14
21
  false
15
22
  end
16
23
  end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "debug"
3
4
  module RubyShell
4
5
  class Chainer
5
- attr_reader :parts
6
+ attr_reader :parts, :options
6
7
 
7
8
  def initialize(command, options = {})
8
9
  @parts = [command]
@@ -19,6 +20,14 @@ module RubyShell
19
20
  self
20
21
  end
21
22
 
23
+ def shared_settings
24
+ %i[env debug]
25
+ end
26
+
27
+ def settings
28
+ @options.select { shared_settings.include?(_1.to_sym) }.transform_keys { :"_#{_1}" }
29
+ end
30
+
22
31
  def method_missing(method_name, *args, &block)
23
32
  if method_name.start_with?(/[^A-Za-z0-9]/)
24
33
  handle_chain(method_name, args.first)
@@ -32,7 +41,13 @@ module RubyShell
32
41
  end
33
42
 
34
43
  def exec_commands
35
- RubyShell::TerminalExecutor.capture(to_shell, @options)
44
+ result = RubyShell::Debugger.run_wrapper(self, debug: @options[:debug]) do
45
+ RubyShell::TerminalExecutor.capture(to_shell, settings)
46
+ end
47
+
48
+ result = RubyShell::Parser.parse(@options[:parse], result) if @options[:parse]
49
+
50
+ result
36
51
  end
37
52
 
38
53
  alias exec exec_commands
@@ -16,7 +16,13 @@ module RubyShell
16
16
  end
17
17
 
18
18
  def exec_command
19
- RubyShell::TerminalExecutor.capture(to_shell, @options)
19
+ result = RubyShell::Debugger.run_wrapper(self, debug: @options[:_debug]) do
20
+ RubyShell::TerminalExecutor.capture(to_shell, @options)
21
+ end
22
+
23
+ result = RubyShell::Parser.parse(@options[:_parse], result) if @options[:_parse]
24
+
25
+ result
20
26
  end
21
27
 
22
28
  alias exec exec_command
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyShell
4
+ module Debugger
5
+ class << self
6
+ def run_wrapper(command, debug: nil)
7
+ if debug || RubyShell.debug?
8
+
9
+ time_one = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+
11
+ result = yield
12
+
13
+ time_two = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+
15
+ RubyShell.log(<<~TEXT
16
+ \nExecuted: #{command.to_shell.chomp}
17
+ Duration: #{format("%.6f", time_two - time_one)}s
18
+ Pid: #{result.metadata[:exit_status].pid}
19
+ Exit code: #{result.metadata[:exit_status].to_i}
20
+ Stdout: #{result.to_s.inspect}
21
+ TEXT
22
+ )
23
+
24
+ result
25
+ else
26
+ yield
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyShell
4
+ module EnvProxy
5
+ class << self
6
+ def env
7
+ @env ||= {}
8
+ end
9
+
10
+ def to_h
11
+ env
12
+ end
13
+
14
+ def []=(key, value)
15
+ env[key.to_s] = value
16
+ end
17
+
18
+ def [](key)
19
+ env[key.to_s]
20
+ end
21
+
22
+ def set(hash)
23
+ @env = hash&.transform_keys(&:to_s) || {}
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,12 +6,18 @@ module RubyShell
6
6
 
7
7
  include RubyShell::OverwritedCommands
8
8
 
9
- def chain(&block)
10
- RubyShell::ChainContext.class_eval(&block).exec_commands
9
+ def chain(options = {}, &block)
10
+ RubyShell::ChainContext.new(options).instance_exec(&block).exec_commands
11
11
  end
12
12
 
13
- def method_missing(method_name, *args)
14
- command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args)
13
+ def parallel(options = {}, &block)
14
+ RubyShell.debug(options[:debug]) do
15
+ RubyShell::ParallelExecutor.new(options)._evaluate(&block)._run
16
+ end
17
+ end
18
+
19
+ def method_missing(method_name, *args, **kwargs)
20
+ command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args, **kwargs)
15
21
 
16
22
  if method_name.to_s.match?(/!$/)
17
23
  command
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyShell
4
+ class ParallelExecutor
5
+ def initialize(options = {})
6
+ @commands = []
7
+ @options = options
8
+
9
+ @queue = Queue.new
10
+ end
11
+
12
+ def shared_settings
13
+ %i[env debug]
14
+ end
15
+
16
+ def settings
17
+ @options.select { shared_settings.include?(_1.to_sym) }.transform_keys { :"_#{_1}" }
18
+ end
19
+
20
+ def _evaluate(&block)
21
+ instance_exec(&block)
22
+
23
+ self
24
+ end
25
+
26
+ def _run
27
+ @commands.each do |command|
28
+ Thread.new do
29
+ @queue << begin
30
+ command.exec
31
+ rescue StandardError => e
32
+ e
33
+ end
34
+ end
35
+ end
36
+
37
+ Enumerator.new do |y|
38
+ @commands.size.times { y << @queue.pop }
39
+ end
40
+ end
41
+
42
+ def chain(options = {}, &block)
43
+ @commands << RubyShell::ChainContext.new(settings.merge(options)).instance_exec(&block)
44
+ end
45
+
46
+ def sh(command, *args)
47
+ method_missing(command, *args)
48
+ end
49
+
50
+ def method_missing(method_name, *args, **kwargs)
51
+ command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args, **settings, **kwargs)
52
+
53
+ @commands << command
54
+ end
55
+
56
+ def respond_to_missing?(_name, _include_private)
57
+ false
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyShell
4
+ class Parser
5
+ class ParserNotFound < StandardError
6
+ end
7
+
8
+ def self.parse(parser_key, value)
9
+ parser_class = locate_parser(parser_key)
10
+
11
+ parser_class.parse(value)
12
+ end
13
+
14
+ def self.locate_parser(parser_key)
15
+ Object.const_get("RubyShell::Parsers::#{parser_key.to_s.split("_").map(&:capitalize).join}")
16
+ rescue NameError
17
+ raise ParserNotFound
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyShell
4
+ module Parsers
5
+ class Base
6
+ def self.parse(_value)
7
+ raise NotImplementedError
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module RubyShell
6
+ module Parsers
7
+ class Csv < Base
8
+ def self.parse(value)
9
+ CSV.parse(value)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyShell
6
+ module Parsers
7
+ class Json < Base
8
+ def self.parse(value)
9
+ JSON.parse(value, symbolize_names: true)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubyShell
6
+ module Parsers
7
+ class Yaml < Base
8
+ def self.parse(value)
9
+ YAML.safe_load(value, permitted_classes: [Symbol])
10
+ end
11
+ end
12
+ end
13
+ end
@@ -3,6 +3,14 @@
3
3
  module RubyShell
4
4
  module Results
5
5
  class StringResult < String
6
+ def initialize(value, **kwargs)
7
+ @metadata = kwargs.delete(:metadata)
8
+
9
+ super
10
+ end
11
+
12
+ attr_reader :metadata
13
+
6
14
  def inspect
7
15
  if $stdin.isatty
8
16
  to_s
@@ -13,7 +13,9 @@ module RubyShell
13
13
  options[:_stdin]
14
14
  end
15
15
 
16
- Open3.popen3(command) do |stdin, stdout, stderr, w_thread|
16
+ env_hash = RubyShell.env.to_h.merge(options[:_env]&.transform_keys(&:to_s) || {})
17
+
18
+ Open3.popen3(env_hash, command) do |stdin, stdout, stderr, w_thread|
17
19
  stdin.write(stdin_value) if stdin_value
18
20
 
19
21
  stdin.close
@@ -64,7 +66,13 @@ module RubyShell
64
66
  )
65
67
  end
66
68
 
67
- RubyShell::Results::StringResult.new(output.chomp)
69
+ RubyShell::Results::StringResult.new(
70
+ output.chomp,
71
+ metadata: {
72
+ command: command,
73
+ exit_status: status
74
+ }
75
+ )
68
76
  end
69
77
  rescue StandardError => e
70
78
  raise e if e.is_a?(RubyShell::CommandError)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyShell
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
data/lib/rubyshell.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "logger"
4
+
3
5
  require_relative "rubyshell/version"
4
6
  require_relative "rubyshell/command"
5
7
  require_relative "rubyshell/chainer"
@@ -10,15 +12,70 @@ require_relative "rubyshell/error"
10
12
  require_relative "rubyshell/results/string_result"
11
13
  require_relative "rubyshell/terminal_executor"
12
14
  require_relative "rubyshell/sanitizer"
15
+ require_relative "rubyshell/parser"
16
+ require_relative "rubyshell/parsers/base"
17
+ require_relative "rubyshell/debugger"
18
+ require_relative "rubyshell/env_proxy"
19
+ require_relative "rubyshell/parallel_executor"
20
+
21
+ module RubyShell
22
+ class << self
23
+ def debug=(value)
24
+ @debug_mode = !!value
25
+ end
26
+
27
+ def debug(value = true) # rubocop:disable Style/OptionalBooleanParameter
28
+ previous_value = @debug_mode
29
+
30
+ @debug_mode = value
31
+
32
+ result = yield
33
+
34
+ @debug_mode = previous_value
35
+
36
+ result
37
+ end
38
+
39
+ def debug?
40
+ @debug_mode == true
41
+ end
42
+
43
+ attr_writer :logger
44
+
45
+ def logger
46
+ @logger ||= Logger.new($stdout)
47
+ end
48
+
49
+ def log_level=(level)
50
+ @log_level = level.to_s
51
+ end
52
+
53
+ def log(text)
54
+ logger.send(@log_level || :info, text)
55
+ end
56
+
57
+ def env
58
+ RubyShell::EnvProxy
59
+ end
60
+
61
+ def config(kwargs)
62
+ env.set(kwargs[:env]) if kwargs[:env]
63
+ end
64
+ end
65
+ end
13
66
 
14
67
  module Kernel
15
- def sh(command = nil, *args, &block)
68
+ def sh(command = nil, *args, **kwargs, &block)
16
69
  if command
17
- RubyShell::Executor.send(command, *args)
70
+ RubyShell::Executor.send(command, *args, **kwargs)
18
71
  elsif block.nil?
19
72
  RubyShell::Executor
20
73
  else
21
- RubyShell::Executor.class_eval(&block)
74
+ RubyShell.config(kwargs)
75
+
76
+ RubyShell.debug(kwargs[:debug]) do
77
+ RubyShell::Executor.class_eval(&block)
78
+ end
22
79
  end
23
80
  end
24
81
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubyshell
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - albertalef
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-23 00:00:00.000000000 Z
11
+ date: 2026-02-06 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A rubist way to run shell commands
14
14
  email:
@@ -28,9 +28,17 @@ files:
28
28
  - lib/rubyshell/chain_context.rb
29
29
  - lib/rubyshell/chainer.rb
30
30
  - lib/rubyshell/command.rb
31
+ - lib/rubyshell/debugger.rb
32
+ - lib/rubyshell/env_proxy.rb
31
33
  - lib/rubyshell/error.rb
32
34
  - lib/rubyshell/executor.rb
33
35
  - lib/rubyshell/overwrited_commands.rb
36
+ - lib/rubyshell/parallel_executor.rb
37
+ - lib/rubyshell/parser.rb
38
+ - lib/rubyshell/parsers/base.rb
39
+ - lib/rubyshell/parsers/csv.rb
40
+ - lib/rubyshell/parsers/json.rb
41
+ - lib/rubyshell/parsers/yaml.rb
34
42
  - lib/rubyshell/results/string_result.rb
35
43
  - lib/rubyshell/sanitizer.rb
36
44
  - lib/rubyshell/terminal_executor.rb