ergane 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +24 -1
- data/LICENSE +18 -17
- data/README.md +259 -10
- data/lib/ergane/argument_definition.rb +15 -0
- data/lib/ergane/command.rb +85 -0
- data/lib/ergane/concerns/inheritance.rb +44 -0
- data/lib/ergane/concerns/option_handling.rb +43 -0
- data/lib/ergane/core_ext/array.rb +13 -0
- data/lib/ergane/core_ext/hash.rb +10 -0
- data/lib/ergane/core_ext/object.rb +43 -0
- data/lib/{core_ext → ergane/core_ext}/option_parser.rb +3 -2
- data/lib/ergane/core_ext/string.rb +22 -0
- data/lib/ergane/dsl/block_dsl.rb +29 -0
- data/lib/ergane/dsl/command_dsl.rb +50 -0
- data/lib/ergane/errors.rb +51 -0
- data/lib/ergane/formatter.rb +29 -0
- data/lib/ergane/help_formatter.rb +101 -0
- data/lib/ergane/option_definition.rb +49 -0
- data/lib/ergane/path_registry.rb +47 -0
- data/lib/ergane/runner.rb +60 -0
- data/lib/ergane/tool.rb +64 -95
- data/lib/ergane/util/debug.rb +24 -0
- data/lib/ergane/util/formatting.rb +31 -0
- data/lib/ergane/version.rb +3 -1
- data/lib/ergane.rb +32 -77
- metadata +69 -72
- data/app/commands/ergane/console.rb +0 -22
- data/bin/ergane +0 -24
- data/lib/ergane/command_definition.rb +0 -176
- data/lib/ergane/debug.rb +0 -75
- data/lib/ergane/helpers/hashall.rb +0 -8
- data/lib/ergane/switch_definition.rb +0 -49
- data/lib/ergane/util.rb +0 -77
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53b7a242e903c28ff9ddb5f33ee399c044fd618f0420cd3868cecb02c4e277e6
|
|
4
|
+
data.tar.gz: 9f8e7b16cc0f64f3547b98e41d973f4b90528215e516873600eb6e953bd2141a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdcf833f1190263dc9f37f470c03d7d150e566175d927e2fec39c036a22111fca8b59c7af4d64a1c1c66af1a455eecb257f66cf2c5276f0f672a513eda123e9a
|
|
7
|
+
data.tar.gz: c85a0eb19d99c6c5424e40655a6c64f33591e04a0d11d576076e2e9a03d2e9ed4f8b26080c21b052145fdff3097e4bd01687226e31abaf84f77013e155954dd9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
1
3
|
## [Unreleased]
|
|
2
4
|
|
|
3
|
-
## [0.1.0] -
|
|
5
|
+
## [0.1.0] - 2026-05-25
|
|
6
|
+
|
|
7
|
+
First release of the rewritten framework. A near-complete rewrite of the
|
|
8
|
+
original `0.0.1` proof of concept.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Dual DSL: class-based and block-based command definitions, both producing the same command tree
|
|
12
|
+
- Zeitwerk autoloading
|
|
13
|
+
- Recursive subcommand resolution via Runner
|
|
14
|
+
- Tool base class with auto-created command base (`MyTool::Command`)
|
|
15
|
+
- Custom `command_class` for shared command behavior
|
|
16
|
+
- Abstract commands for grouping shared options
|
|
17
|
+
- Colorized help output with box-drawing characters
|
|
18
|
+
- Did-you-mean suggestions for unknown commands (Levenshtein)
|
|
19
|
+
- `--help` and `--version` flag handling
|
|
20
|
+
- Options, flags, and positional arguments, with optional values for options
|
|
21
|
+
- Path abbreviation registry (`Ergane.paths` / `PathRegistry`): collapses registered prefixes (default `$HOME` → `~`), longest-prefix-wins and boundary-safe; commands call `abbreviate_path`
|
|
22
|
+
- Interactive output helpers: `Ergane::Formatter.confirm?` and `Ergane::Formatter.time_ago`
|
|
23
|
+
- Core extensions (`blank?`, `present?`, `try`, `underscore`, `demodulize`, `Array.wrap`, `Hash#&`)
|
|
24
|
+
- `OptionParser#order_recognized!` for multi-level flag passthrough
|
|
25
|
+
|
|
26
|
+
## [0.0.1] - 2023-05-08
|
|
4
27
|
|
|
5
28
|
- Initial release
|
data/LICENSE
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
Copyright (c) 2026 Twilight Coders, LLC
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MIT License
|
|
4
4
|
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
-
of this software and associated documentation files (the
|
|
7
|
-
in the Software without restriction, including
|
|
8
|
-
to use, copy, modify, merge, publish,
|
|
9
|
-
copies of the Software, and to
|
|
10
|
-
furnished to do so, subject to
|
|
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:
|
|
11
12
|
|
|
12
|
-
The above copyright notice and this permission notice shall be
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
14
15
|
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
THE SOFTWARE.
|
|
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
CHANGED
|
@@ -1,18 +1,267 @@
|
|
|
1
|
+
<img src="https://github.com/TwilightCoders/ergane/blob/main/media/ergane.png?raw=true" alt="Athena Ergane" width="400" style="float: right"/>
|
|
2
|
+
|
|
1
3
|
# Ergane
|
|
2
|
-
**The patron goddess of craftsmen and artisans**
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
[](https://rubygems.org/gems/ergane)
|
|
6
|
+
[](https://github.com/TwilightCoders/ergane/actions/workflows/ci.yml)
|
|
7
|
+
[](https://qlty.sh/gh/TwilightCoders/projects/ergane)
|
|
8
|
+
[](https://qlty.sh/gh/TwilightCoders/projects/ergane)
|
|
9
|
+
|
|
10
|
+
A lightweight, powerful CLI framework for Ruby. Define commands using class inheritance or block DSL — both produce the same command tree.
|
|
11
|
+
|
|
12
|
+
An alternative to other similar utilities with a cleaner, more Ruby-native design.
|
|
13
|
+
|
|
14
|
+
Named after [Athena Ergane](https://en.wikipedia.org/wiki/Athena#Ergane), patron of craftsmen and toolmakers.
|
|
6
15
|
|
|
7
16
|
## Requirements
|
|
8
|
-
Ruby 2.7+
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
Ruby 3.1+
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "ergane"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "ergane"
|
|
30
|
+
|
|
31
|
+
class MyCLI < Ergane::Tool
|
|
32
|
+
tool_name :mycli
|
|
33
|
+
version "1.0.0"
|
|
34
|
+
description "My awesome CLI tool"
|
|
35
|
+
|
|
36
|
+
command :greet do
|
|
37
|
+
description "Say hello"
|
|
38
|
+
option :name, String, short: :n, default: "world"
|
|
39
|
+
|
|
40
|
+
run { puts "Hello #{options[:name]}!" }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
MyCLI.start(ARGV)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ mycli greet -n 'Johnny Appleseed'
|
|
49
|
+
Hello Johnny Appleseed!
|
|
50
|
+
|
|
51
|
+
$ mycli --help
|
|
52
|
+
My awesome CLI tool
|
|
53
|
+
|
|
54
|
+
Version: 1.0.0
|
|
55
|
+
|
|
56
|
+
Usage: mycli [options] [subcommand]
|
|
57
|
+
|
|
58
|
+
Subcommands:
|
|
59
|
+
greet Say hello
|
|
60
|
+
|
|
61
|
+
$ mycli greet --help
|
|
62
|
+
Say hello
|
|
63
|
+
|
|
64
|
+
Usage: greet [options]
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
-n, --name=VALUE (default: world)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Defining Commands
|
|
71
|
+
|
|
72
|
+
Ergane supports two equivalent styles for defining commands. Both produce the same underlying `Command` subclass.
|
|
73
|
+
|
|
74
|
+
### Block-based DSL
|
|
75
|
+
|
|
76
|
+
Best for quick, self-contained commands:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
class MyCLI < Ergane::Tool
|
|
80
|
+
tool_name :mycli
|
|
81
|
+
version "1.0.0"
|
|
82
|
+
|
|
83
|
+
command :deploy do
|
|
84
|
+
description "Deploy the application"
|
|
85
|
+
option :env, String, short: :e, default: "staging"
|
|
86
|
+
flag :force, short: :f, description: "Skip confirmation"
|
|
87
|
+
|
|
88
|
+
run do |*targets|
|
|
89
|
+
puts "Deploying #{targets.join(', ')} to #{options[:env]}"
|
|
90
|
+
puts "Force mode!" if options[:force]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Class-based
|
|
97
|
+
|
|
98
|
+
Best for complex commands that need helper methods, mixins, or testing in isolation.
|
|
99
|
+
|
|
100
|
+
Defining a Tool automatically creates a `MyCLI::Command` base class for your commands to inherit from:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class Deploy < MyCLI::Command
|
|
104
|
+
self.command_name = :deploy
|
|
105
|
+
description "Deploy the application"
|
|
106
|
+
option :env, String, short: :e, default: "staging"
|
|
107
|
+
flag :force, short: :f, description: "Skip confirmation"
|
|
108
|
+
|
|
109
|
+
def run(*targets)
|
|
110
|
+
puts "Deploying #{targets.join(', ')} to #{options[:env]}"
|
|
111
|
+
puts "Force mode!" if options[:force]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def confirm?
|
|
117
|
+
return true if options[:force]
|
|
118
|
+
# ...
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Nested Subcommands
|
|
124
|
+
|
|
125
|
+
Both styles support nesting to arbitrary depth:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class MyCLI < Ergane::Tool
|
|
129
|
+
tool_name :mycli
|
|
130
|
+
version "1.0.0"
|
|
131
|
+
|
|
132
|
+
command :server do
|
|
133
|
+
description "Server management"
|
|
134
|
+
|
|
135
|
+
command :start do
|
|
136
|
+
description "Start the server"
|
|
137
|
+
option :port, String, short: :p, default: "3000"
|
|
138
|
+
|
|
139
|
+
run { puts "Starting on port #{options[:port]}" }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
command :stop do
|
|
143
|
+
description "Stop the server"
|
|
144
|
+
run { puts "Stopping server" }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
$ mycli server start -p 8080
|
|
152
|
+
Starting on port 8080
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Options and Flags
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Typed option (requires a value)
|
|
159
|
+
option :env, String, short: :e, description: "Target environment", default: "staging"
|
|
160
|
+
|
|
161
|
+
# Boolean flag (no value, defaults to false)
|
|
162
|
+
flag :verbose, short: :v, description: "Enable verbose output"
|
|
163
|
+
|
|
164
|
+
# Positional argument
|
|
165
|
+
argument :target, description: "Deploy target"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Options are accessed via the `options` hash:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
run do |*args|
|
|
172
|
+
puts options[:env] # => "staging"
|
|
173
|
+
puts options[:verbose] # => false
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Loading Commands from Files
|
|
178
|
+
|
|
179
|
+
For larger CLIs, organize commands in separate files:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
class MyCLI < Ergane::Tool
|
|
183
|
+
tool_name :mycli
|
|
184
|
+
version "1.0.0"
|
|
185
|
+
|
|
186
|
+
load_commands(File.expand_path("commands/**/*.rb", __dir__))
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Each file defines a Command subclass that auto-registers via Ruby's `inherited` hook:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# commands/deploy.rb
|
|
194
|
+
class Deploy < MyCLI::Command
|
|
195
|
+
self.command_name = :deploy
|
|
196
|
+
description "Deploy the application"
|
|
197
|
+
|
|
198
|
+
def run(*targets)
|
|
199
|
+
# ...
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Custom Command Base
|
|
205
|
+
|
|
206
|
+
For shared behavior across all commands, provide your own base class:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class MyBaseCommand < Ergane::Command
|
|
210
|
+
self.abstract_class = true
|
|
211
|
+
flag :verbose, short: :v, description: "Verbose output"
|
|
212
|
+
|
|
213
|
+
def log(msg)
|
|
214
|
+
puts msg if options[:verbose]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
class MyCLI < Ergane::Tool
|
|
219
|
+
tool_name :mycli
|
|
220
|
+
version "1.0.0"
|
|
221
|
+
command_class MyBaseCommand
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
class Deploy < MyBaseCommand
|
|
225
|
+
self.command_name = :deploy
|
|
226
|
+
description "Deploy the application"
|
|
227
|
+
|
|
228
|
+
def run(*targets)
|
|
229
|
+
log "Deploying #{targets.join(', ')}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Abstract Commands
|
|
235
|
+
|
|
236
|
+
Group related commands with shared options:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
class DatabaseCommand < MyCLI::Command
|
|
240
|
+
self.abstract_class = true
|
|
241
|
+
option :database, String, short: :d, default: "primary"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class Migrate < DatabaseCommand
|
|
245
|
+
self.command_name = :migrate
|
|
246
|
+
description "Run migrations"
|
|
247
|
+
|
|
248
|
+
def run
|
|
249
|
+
puts "Migrating #{options[:database]}"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Development
|
|
255
|
+
|
|
256
|
+
After checking out the repo, run `bundle` to install dependencies. Then, run `bundle exec rspec` to run the tests.
|
|
11
257
|
|
|
12
258
|
## Contributing
|
|
13
259
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
260
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
261
|
+
<https://github.com/TwilightCoders/ergane>. This project is intended to be a safe,
|
|
262
|
+
welcoming space for collaboration, and contributors are expected to adhere to the
|
|
263
|
+
[Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
class ArgumentDefinition
|
|
5
|
+
attr_reader :name, :type, :description, :required, :default
|
|
6
|
+
|
|
7
|
+
def initialize(name, type = String, description: nil, required: true, default: nil)
|
|
8
|
+
@name = name.to_sym
|
|
9
|
+
@type = type
|
|
10
|
+
@description = description
|
|
11
|
+
@required = required
|
|
12
|
+
@default = default
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
class Command
|
|
5
|
+
include Concerns::Inheritance
|
|
6
|
+
include Concerns::OptionHandling
|
|
7
|
+
extend DSL::CommandDSL
|
|
8
|
+
|
|
9
|
+
self.abstract_class = true
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def command_name=(name)
|
|
13
|
+
@command_name = name&.to_sym
|
|
14
|
+
return unless @command_name
|
|
15
|
+
|
|
16
|
+
parent = superclass
|
|
17
|
+
if parent.respond_to?(:tool) && parent.abstract_class?
|
|
18
|
+
parent.inherited_command_name_set(self)
|
|
19
|
+
else
|
|
20
|
+
register_subcommand(self)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def command_name
|
|
25
|
+
@command_name || derive_command_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def terms
|
|
29
|
+
[command_name, *aliases].compact.uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subcommands
|
|
33
|
+
@subcommands ||= {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inherited(subclass)
|
|
37
|
+
super
|
|
38
|
+
subclass.instance_variable_set(:@option_definitions, option_definitions.dup)
|
|
39
|
+
subclass.instance_variable_set(:@argument_definitions, argument_definitions.dup)
|
|
40
|
+
subclass.instance_variable_set(:@subcommands, {})
|
|
41
|
+
register_subcommand(subclass)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def derive_command_name
|
|
47
|
+
return nil if self == Command || abstract_class?
|
|
48
|
+
base = name&.demodulize
|
|
49
|
+
return nil unless base
|
|
50
|
+
base.sub(/Command$/, "").underscore.to_sym
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def register_subcommand(subclass)
|
|
54
|
+
parent = subclass.superclass
|
|
55
|
+
return if parent == Command || parent.abstract_class?
|
|
56
|
+
cmd_name = subclass.command_name
|
|
57
|
+
return unless cmd_name
|
|
58
|
+
parent.subcommands[cmd_name] = subclass
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_reader :options
|
|
63
|
+
|
|
64
|
+
def abbreviate_path(path)
|
|
65
|
+
Ergane.paths.abbreviate(path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(argv = [])
|
|
69
|
+
@options = self.class.build_default_options
|
|
70
|
+
@argv = parse_options(argv.dup)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def args
|
|
74
|
+
@argv
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run(*run_args)
|
|
78
|
+
if self.class.subcommands.any?
|
|
79
|
+
$stdout.puts HelpFormatter.new(self.class).format
|
|
80
|
+
else
|
|
81
|
+
raise AbstractCommand, "#{self.class.name}#run is not implemented"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
module Concerns
|
|
5
|
+
module Inheritance
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def abstract_class=(value)
|
|
12
|
+
@abstract_class = value
|
|
13
|
+
# Unregister from parent's subcommands when marked abstract
|
|
14
|
+
if value && respond_to?(:command_name) && self < Ergane::Command
|
|
15
|
+
parent = superclass
|
|
16
|
+
parent.subcommands.delete(command_name) if parent.respond_to?(:subcommands)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def abstract_class?
|
|
21
|
+
@abstract_class == true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the class descending directly from Ergane::Command, or
|
|
25
|
+
# an abstract class, if any, in the inheritance hierarchy.
|
|
26
|
+
#
|
|
27
|
+
# If A extends Command, A.base_class returns A.
|
|
28
|
+
# If B < A through some hierarchy, B.base_class returns A.
|
|
29
|
+
# If A is abstract, both B.base_class and C.base_class return B.
|
|
30
|
+
def base_class
|
|
31
|
+
unless self < Ergane::Command
|
|
32
|
+
raise Ergane::Error, "#{name} doesn't belong in a hierarchy descending from Ergane::Command"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if superclass == Ergane::Command || superclass.abstract_class?
|
|
36
|
+
self
|
|
37
|
+
else
|
|
38
|
+
superclass.base_class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
module Concerns
|
|
5
|
+
module OptionHandling
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def option_definitions
|
|
12
|
+
@option_definitions ||= {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def argument_definitions
|
|
16
|
+
@argument_definitions ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def build_default_options
|
|
20
|
+
option_definitions.each_with_object({}) do |(name, defn), hash|
|
|
21
|
+
hash[name] = defn.default_value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_option_parser(store)
|
|
26
|
+
::OptionParser.new do |parser|
|
|
27
|
+
option_definitions.each_value do |defn|
|
|
28
|
+
defn.attach(parser, store)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_options(argv)
|
|
37
|
+
parser = self.class.build_option_parser(@options)
|
|
38
|
+
parser.order_recognized!(argv)
|
|
39
|
+
argv
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hash
|
|
4
|
+
# Hash intersection by keys. Values come from the receiver.
|
|
5
|
+
# {a: 1, b: 2} & {a: 10, c: 3} # => {a: 1}
|
|
6
|
+
def &(other)
|
|
7
|
+
shared = keys & other.keys
|
|
8
|
+
shared.each_with_object({}) { |k, h| h[k] = self[k] }
|
|
9
|
+
end unless method_defined?(:&)
|
|
10
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Object
|
|
4
|
+
def blank?
|
|
5
|
+
respond_to?(:empty?) ? !!empty? : !self
|
|
6
|
+
end unless method_defined?(:blank?)
|
|
7
|
+
|
|
8
|
+
def present?
|
|
9
|
+
!blank?
|
|
10
|
+
end unless method_defined?(:present?)
|
|
11
|
+
|
|
12
|
+
def try(method_name = nil, *args, &block)
|
|
13
|
+
if method_name
|
|
14
|
+
respond_to?(method_name) ? public_send(method_name, *args, &block) : nil
|
|
15
|
+
elsif block
|
|
16
|
+
yield self
|
|
17
|
+
end
|
|
18
|
+
end unless method_defined?(:try)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class NilClass
|
|
22
|
+
def blank? = true unless method_defined?(:blank?)
|
|
23
|
+
def try(*) = nil unless method_defined?(:try)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class FalseClass
|
|
27
|
+
def blank? = true unless method_defined?(:blank?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class TrueClass
|
|
31
|
+
def blank? = false unless method_defined?(:blank?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class String
|
|
35
|
+
# Override Object#blank? to also catch whitespace-only strings
|
|
36
|
+
def blank?
|
|
37
|
+
empty? || /\A[[:space:]]*\z/.match?(self)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Numeric
|
|
42
|
+
def blank? = false unless method_defined?(:blank?)
|
|
43
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
class OptionParser
|
|
3
4
|
# Like order!, but leave any unrecognized --switches alone
|
|
5
|
+
# instead of raising InvalidOption.
|
|
4
6
|
def order_recognized!(args)
|
|
5
7
|
extra_opts = []
|
|
6
8
|
begin
|
|
@@ -11,5 +13,4 @@ class OptionParser
|
|
|
11
13
|
end
|
|
12
14
|
args[0, 0] = extra_opts
|
|
13
15
|
end
|
|
14
|
-
|
|
15
16
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class String
|
|
4
|
+
# "DeployCommand" -> "deploy_command"
|
|
5
|
+
# "Ergane::Deploy" -> "ergane/deploy"
|
|
6
|
+
def underscore
|
|
7
|
+
gsub("::", "/")
|
|
8
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
9
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
10
|
+
.tr("-", "_")
|
|
11
|
+
.downcase
|
|
12
|
+
end unless method_defined?(:underscore)
|
|
13
|
+
|
|
14
|
+
# "Ergane::Deploy" -> "Deploy"
|
|
15
|
+
def demodulize
|
|
16
|
+
if (index = rindex("::"))
|
|
17
|
+
self[(index + 2)..]
|
|
18
|
+
else
|
|
19
|
+
dup
|
|
20
|
+
end
|
|
21
|
+
end unless method_defined?(:demodulize)
|
|
22
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
module DSL
|
|
5
|
+
class BlockDSL
|
|
6
|
+
def initialize(command_class)
|
|
7
|
+
@command_class = command_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run(&block)
|
|
11
|
+
@command_class.define_method(:run) { |*args| instance_exec(*args, &block) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def respond_to_missing?(name, include_private = false)
|
|
17
|
+
@command_class.respond_to?(name) || super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def method_missing(name, *args, **opts, &block)
|
|
21
|
+
if @command_class.respond_to?(name)
|
|
22
|
+
@command_class.public_send(name, *args, **opts, &block)
|
|
23
|
+
else
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|