smol 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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/lib/smol/app.rb ADDED
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ class App
5
+ class << self
6
+ def inherited(subclass)
7
+ super
8
+ subclass.instance_variable_set(:@commands, [])
9
+ subclass.instance_variable_set(:@checks, [])
10
+ subclass.instance_variable_set(:@mounts, {})
11
+
12
+ parent_module = find_parent_module(subclass)
13
+ setup_registry_methods(parent_module, subclass) if parent_module
14
+ end
15
+
16
+ def banner(text = nil)
17
+ @banner = text if text
18
+ @banner || ""
19
+ end
20
+
21
+ def cli(enabled = nil)
22
+ @cli_enabled = enabled unless enabled.nil?
23
+ @cli_enabled.nil? ? true : @cli_enabled
24
+ end
25
+
26
+ def repl(enabled = nil)
27
+ @repl_enabled = enabled unless enabled.nil?
28
+ @repl_enabled.nil? ? true : @repl_enabled
29
+ end
30
+
31
+ def boot(mode = nil)
32
+ @boot_mode = mode if mode
33
+ @boot_mode || :help
34
+ end
35
+
36
+ def history_file(path = nil)
37
+ @history_file = path if path
38
+ @history_file
39
+ end
40
+
41
+ def config
42
+ @config ||= Config.new
43
+ end
44
+
45
+ def commands
46
+ @commands ||= []
47
+ end
48
+
49
+ def checks
50
+ @checks ||= []
51
+ end
52
+
53
+ def mounts
54
+ @mounts ||= {}
55
+ end
56
+
57
+ def mount(app_class, as:)
58
+ mounts[as.to_s] = app_class
59
+ end
60
+
61
+ def find_command(name)
62
+ # Check for mounted app prefix (e.g., "admin:users")
63
+ if name.include?(":")
64
+ prefix, sub_name = name.split(":", 2)
65
+ if mounts[prefix]
66
+ return mounts[prefix].find_command(sub_name)
67
+ end
68
+ end
69
+
70
+ commands.find { |c| c.matches?(name) }
71
+ end
72
+
73
+ def find_mount(name)
74
+ mounts[name.to_s]
75
+ end
76
+
77
+ def register_command(command_class)
78
+ commands << command_class
79
+ end
80
+
81
+ def register_check(check_class)
82
+ checks << check_class
83
+ end
84
+
85
+ def register(command_class)
86
+ @explicit_registration = true
87
+ commands << command_class
88
+ end
89
+
90
+ def explicit_registration?
91
+ @explicit_registration || false
92
+ end
93
+
94
+ private
95
+
96
+ def find_parent_module(subclass)
97
+ parts = subclass.name&.split("::")
98
+ return nil unless parts && parts.size > 1
99
+
100
+ parent_name = parts[0..-2].join("::")
101
+ begin
102
+ Object.const_get(parent_name)
103
+ rescue NameError
104
+ nil
105
+ end
106
+ end
107
+
108
+ def setup_registry_methods(parent_module, app_class)
109
+ unless parent_module.respond_to?(:register_command)
110
+ parent_module.define_singleton_method(:register_command) do |cmd|
111
+ app_class.register_command(cmd)
112
+ end
113
+ end
114
+ unless parent_module.respond_to?(:register_check)
115
+ parent_module.define_singleton_method(:register_check) do |check|
116
+ app_class.register_check(check)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ module AppLookup
5
+ private
6
+
7
+ def app_class
8
+ parts = self.class.name.split("::")
9
+ parent = Object.const_get(parts.first)
10
+ parent.const_get(:App)
11
+ end
12
+ end
13
+ end
data/lib/smol/check.rb ADDED
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ class Check
5
+ include AppLookup
6
+
7
+ class << self
8
+ def inherited(subclass)
9
+ super
10
+ register_to_app(subclass)
11
+ end
12
+
13
+ private
14
+
15
+ def register_to_app(subclass)
16
+ parts = subclass.name&.split("::")
17
+ return unless parts && parts.size > 1
18
+
19
+ parts[0..-2].size.times do |i|
20
+ candidate_name = parts[0..-(i + 2)].join("::")
21
+ begin
22
+ candidate = Object.const_get(candidate_name)
23
+ rescue NameError
24
+ next
25
+ end
26
+
27
+ if candidate.respond_to?(:register_check)
28
+ app_class = find_app_class_for(candidate)
29
+ return if app_class&.explicit_registration?
30
+
31
+ candidate.register_check(subclass)
32
+ return
33
+ end
34
+ end
35
+ end
36
+
37
+ def find_app_class_for(candidate)
38
+ return candidate if candidate.respond_to?(:explicit_registration?)
39
+
40
+ if candidate.const_defined?(:App, false)
41
+ app = candidate.const_get(:App)
42
+ return app if app.respond_to?(:explicit_registration?)
43
+ end
44
+ nil
45
+ end
46
+
47
+ public
48
+
49
+ def check_name
50
+ name.split("::").last
51
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
52
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
53
+ .downcase
54
+ .tr("_", " ")
55
+ end
56
+ end
57
+
58
+ def pass(message)
59
+ CheckResult.new(passed: true, message: message)
60
+ end
61
+
62
+ def fail(message)
63
+ CheckResult.new(passed: false, message: message)
64
+ end
65
+
66
+ def config
67
+ app_class.config
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ CheckResult = Struct.new(:passed, :message, keyword_init: true) do
5
+ def passed?
6
+ passed
7
+ end
8
+
9
+ def failed?
10
+ !passed
11
+ end
12
+
13
+ def to_s
14
+ status = passed? ? "passed" : "failed"
15
+ "#{status}: #{message}"
16
+ end
17
+ end
18
+ end
data/lib/smol/cli.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ class CLI
5
+ include Output
6
+ include ConfigDisplay
7
+ using Colors
8
+
9
+ def initialize(app, prompt:, history: true)
10
+ @app = app
11
+ @prompt = prompt
12
+ @history = history
13
+ end
14
+
15
+ def run(args)
16
+ if args.empty?
17
+ if @app.repl
18
+ REPL.new(@app, prompt: @prompt, history: @history, history_file: history_file_path).run
19
+ else
20
+ usage
21
+ exit 1
22
+ end
23
+ return
24
+ end
25
+
26
+ unless @app.cli
27
+ failure "CLI mode is disabled"
28
+ hint "run without arguments for interactive mode" if @app.repl
29
+ exit 1
30
+ end
31
+
32
+ cmd_name, *cmd_args = args
33
+
34
+ case cmd_name
35
+ when "help", "-h", "--help"
36
+ usage
37
+ exit 1
38
+ when "config"
39
+ show_config
40
+ return
41
+ when "config:set"
42
+ set_config(cmd_args[0], cmd_args[1])
43
+ return
44
+ end
45
+
46
+ klass = @app.find_command(cmd_name)
47
+
48
+ if klass.nil?
49
+ usage
50
+ exit 1
51
+ end
52
+
53
+ positional, opts = klass.parse_options(cmd_args)
54
+ result = klass.new.call(*positional, **opts)
55
+
56
+ if result == true || result == false
57
+ exit(result ? 0 : 1)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def usage
64
+ banner @app.banner
65
+
66
+ out.puts <<~USAGE
67
+ #{@prompt.bold} - CLI app
68
+
69
+ #{"usage:".bold}
70
+ ./#{@prompt}.rb start interactive mode
71
+ ./#{@prompt}.rb <command> run a single command
72
+
73
+ #{"commands:".bold}
74
+ USAGE
75
+
76
+ grouped = @app.commands.group_by(&:group)
77
+ ungrouped = grouped.delete(nil) || []
78
+
79
+ ungrouped.each do |cmd|
80
+ out.puts " #{cmd.usage.ljust(34)}#{cmd.desc}"
81
+ end
82
+
83
+ grouped.keys.sort.each do |group_name|
84
+ nl
85
+ out.puts " #{group_name}:".bold
86
+ grouped[group_name].each do |cmd|
87
+ out.puts " #{cmd.usage.ljust(32)}#{cmd.desc}"
88
+ end
89
+ end
90
+
91
+ if @app.mounts.any?
92
+ nl
93
+ out.puts " #{"sub-apps:".bold}"
94
+ @app.mounts.each do |name, app_class|
95
+ out.puts " #{(name + ":*").ljust(32)}#{app_class.banner.empty? ? name : app_class.banner}"
96
+ end
97
+ end
98
+
99
+ out.puts " #{"config".ljust(34)}show current config"
100
+ out.puts " #{"config:set <key> <value>".ljust(34)}set a config value"
101
+
102
+ nl
103
+ show_config
104
+ nl
105
+
106
+ out.puts "#{"environment:".bold}"
107
+
108
+ @app.config.each do |key, _, setting|
109
+ line = " #{key.to_s.upcase}"
110
+ line += " - #{setting[:desc]}" if setting[:desc]
111
+ out.puts line
112
+ end
113
+ end
114
+
115
+ def history_file_path
116
+ @app.history_file || File.expand_path("~/.smol_#{@prompt}_history")
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ module Coercion
5
+ TRUTHY_VALUES = %w[true 1 yes].freeze
6
+
7
+ def coerce_value(raw, type)
8
+ case type
9
+ when :integer
10
+ raw.to_i
11
+ when :boolean
12
+ TRUTHY_VALUES.include?(raw.to_s.downcase)
13
+ else
14
+ raw
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ module Colors
5
+ refine String do
6
+ def green
7
+ "\e[32m#{self}\e[0m"
8
+ end
9
+
10
+ def red
11
+ "\e[31m#{self}\e[0m"
12
+ end
13
+
14
+ def yellow
15
+ "\e[33m#{self}\e[0m"
16
+ end
17
+
18
+ def bold
19
+ "\e[1m#{self}\e[0m"
20
+ end
21
+
22
+ def dim
23
+ "\e[2m#{self}\e[0m"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smol
4
+ class Command
5
+ include AppLookup
6
+
7
+ class << self
8
+ include Coercion
9
+
10
+ def inherited(subclass)
11
+ super
12
+ subclass.prepend ErrorHandler
13
+ subclass.prepend Callbacks
14
+ subclass.prepend AutoMessage
15
+ register_to_app(subclass)
16
+ end
17
+
18
+ private
19
+
20
+ def register_to_app(subclass)
21
+ parts = subclass.name&.split("::")
22
+ return unless parts && parts.size > 1
23
+
24
+ parts[0..-2].size.times do |i|
25
+ candidate_name = parts[0..-(i + 2)].join("::")
26
+ begin
27
+ candidate = Object.const_get(candidate_name)
28
+ rescue NameError
29
+ next
30
+ end
31
+
32
+ if candidate.respond_to?(:register_command)
33
+ app_class = find_app_class_for(candidate)
34
+ return if app_class&.explicit_registration?
35
+
36
+ candidate.register_command(subclass)
37
+ return
38
+ end
39
+ end
40
+ end
41
+
42
+ def find_app_class_for(candidate)
43
+ return candidate if candidate.respond_to?(:explicit_registration?)
44
+
45
+ if candidate.const_defined?(:App, false)
46
+ app = candidate.const_get(:App)
47
+ return app if app.respond_to?(:explicit_registration?)
48
+ end
49
+ nil
50
+ end
51
+
52
+ public
53
+
54
+ def command_name(text = nil)
55
+ if text
56
+ @command_name = text
57
+ else
58
+ @command_name || derive_command_name
59
+ end
60
+ end
61
+
62
+ def title(text = nil)
63
+ @title = text if text
64
+ @title
65
+ end
66
+
67
+ def explain(text = nil)
68
+ @explain = text if text
69
+ @explain
70
+ end
71
+
72
+ def aliases(*args)
73
+ @aliases = args if args.any?
74
+ @aliases || []
75
+ end
76
+
77
+ def args(*args)
78
+ @args = args if args.any?
79
+ @args || []
80
+ end
81
+
82
+ def option(name, short: nil, type: :string, default: nil, desc: nil)
83
+ @options ||= {}
84
+ @options[name] = { short: short, type: type, default: default, desc: desc }
85
+ end
86
+
87
+ def options
88
+ @options || {}
89
+ end
90
+
91
+ def desc(text = nil)
92
+ @desc = text if text
93
+ @desc || ""
94
+ end
95
+
96
+ def group(text = nil)
97
+ @group = text if text
98
+ @group
99
+ end
100
+
101
+ def before_action(method_name)
102
+ @before_actions ||= []
103
+ @before_actions << method_name
104
+ end
105
+
106
+ def before_actions
107
+ @before_actions || []
108
+ end
109
+
110
+ def after_action(method_name)
111
+ @after_actions ||= []
112
+ @after_actions << method_name
113
+ end
114
+
115
+ def after_actions
116
+ @after_actions || []
117
+ end
118
+
119
+ def matches?(input)
120
+ input == command_name.to_s || aliases.map(&:to_s).include?(input)
121
+ end
122
+
123
+ def usage
124
+ parts = [command_name]
125
+ parts += args.map { |a| "<#{a}>" }
126
+ options.each do |name, opt|
127
+ flag = opt[:short] ? "-#{opt[:short]}/--#{name}" : "--#{name}"
128
+ parts << "[#{flag}]"
129
+ end
130
+ parts.join(" ")
131
+ end
132
+
133
+ def parse_options(argv)
134
+ positional = []
135
+ opts = options.transform_values { |o| o[:default] }
136
+
137
+ i = 0
138
+ while i < argv.length
139
+ arg = argv[i]
140
+ if arg.start_with?("--")
141
+ key, value = arg[2..].split("=", 2)
142
+ key = key.tr("-", "_").to_sym
143
+ if options[key]
144
+ value ||= argv[i += 1]
145
+ opts[key] = coerce_value(value, options[key][:type])
146
+ end
147
+ elsif arg.start_with?("-") && arg.length == 2
148
+ short = arg[1]
149
+ opt_name = options.find { |_, o| o[:short]&.to_s == short }&.first
150
+ if opt_name
151
+ value = argv[i += 1]
152
+ opts[opt_name] = coerce_value(value, options[opt_name][:type])
153
+ end
154
+ else
155
+ positional << arg
156
+ end
157
+ i += 1
158
+ end
159
+
160
+ [positional, opts]
161
+ end
162
+
163
+ def rescue_from(*exceptions, with: nil, &block)
164
+ handler = block || with
165
+ raise ArgumentError, "rescue_from requires a block or :with handler" unless handler
166
+
167
+ @error_handlers ||= []
168
+ exceptions.each do |exception|
169
+ @error_handlers << [exception, handler]
170
+ end
171
+ end
172
+
173
+ def error_handlers
174
+ @error_handlers || []
175
+ end
176
+
177
+ private
178
+
179
+ def derive_command_name
180
+ return "anonymous" unless name
181
+
182
+ name.split("::").last
183
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
184
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
185
+ .downcase
186
+ end
187
+ end
188
+
189
+ module AutoMessage
190
+ def call(*args, **opts)
191
+ if self.class.title
192
+ header self.class.title
193
+ desc self.class.explain if self.class.explain
194
+ nl
195
+ end
196
+ super
197
+ end
198
+ end
199
+
200
+ module Callbacks
201
+ def call(*args, **opts)
202
+ self.class.before_actions.each do |method_name|
203
+ result = send(method_name, *args, **opts)
204
+ return result if result == false
205
+ end
206
+
207
+ result = super
208
+
209
+ self.class.after_actions.each do |method_name|
210
+ send(method_name, *args, result: result, **opts)
211
+ end
212
+
213
+ result
214
+ end
215
+ end
216
+
217
+ module ErrorHandler
218
+ def call(*args, **opts)
219
+ super
220
+ rescue => e
221
+ handler = find_error_handler(e)
222
+ if handler
223
+ if handler.is_a?(Symbol)
224
+ send(handler, e)
225
+ else
226
+ instance_exec(e, &handler)
227
+ end
228
+ else
229
+ raise
230
+ end
231
+ end
232
+
233
+ private
234
+
235
+ def find_error_handler(error)
236
+ self.class.error_handlers.each do |exception_class, handler|
237
+ return handler if error.is_a?(exception_class)
238
+ end
239
+ nil
240
+ end
241
+ end
242
+
243
+ include Output
244
+ include Input
245
+
246
+ def config
247
+ app_class.config
248
+ end
249
+
250
+ def app
251
+ app_class
252
+ end
253
+
254
+ def checking(name)
255
+ warning "checking: #{name}"
256
+ nl
257
+ end
258
+
259
+ def dropping(target)
260
+ warning "dropping: #{target}"
261
+ nl
262
+ end
263
+
264
+ def done(hint_text = nil)
265
+ nl
266
+ success "done"
267
+ hint hint_text if hint_text
268
+ end
269
+
270
+ def checks_passed?(all_passed, pass_hint: nil, fail_hint: nil)
271
+ nl
272
+ if all_passed
273
+ success "all checks passed"
274
+ hint pass_hint if pass_hint
275
+ else
276
+ failure "some checks failed"
277
+ hint fail_hint if fail_hint
278
+ end
279
+ all_passed
280
+ end
281
+
282
+ def run_checks(*check_classes, args: [])
283
+ results = check_classes.map do |klass|
284
+ result = klass.new(*args).call
285
+ check_result(klass.check_name, result)
286
+ nl
287
+ result.passed?
288
+ end
289
+ results.all?
290
+ end
291
+ end
292
+ end