lux-hammer 0.2.6 → 0.2.8
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/.version +1 -1
- data/AGENTS.md +23 -0
- data/README.md +10 -1
- data/lib/hammer/builder.rb +33 -0
- data/lib/hammer/dotenv.rb +50 -0
- data/lib/hammer/loader.rb +1 -1
- data/lib/hammer/option.rb +12 -9
- data/lib/lux-hammer.rb +165 -35
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e861fb5cdf9bbcc0533942927d96f35348fbb50614c1dd50b16279f1d81d2d3
|
|
4
|
+
data.tar.gz: 1a31f522be7d9c75847ce820169da0c5b9a4418522c224d3d284ce004ecbcd2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a1d7af3b3523903b10bf5a1b0af1d4d1c340b8a1a6736d6b9b0884f0cc0adca7eaa46079dfb24274147043c4e79b9a8ea497afcb7b6c80389ec73c4ee302d28f
|
|
7
|
+
data.tar.gz: e436739c8da3cb6dcfbd64135405930e70ba07eab53a5f1c880d3240319ce9c77118d61ba6fb766ef5817dfa54b0fa37fe17d1167236b4221afc156e04f3463f
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.8
|
data/AGENTS.md
CHANGED
|
@@ -84,6 +84,12 @@ At class or `Hammerfile` scope:
|
|
|
84
84
|
|
|
85
85
|
* `task :name do ... end`
|
|
86
86
|
* `namespace :name do ... end`
|
|
87
|
+
* `dotenv false` - opt out of auto `.env` / `.env.local` loading.
|
|
88
|
+
Only meaningful from the `hammer` binary (`Hammer.cli`); the load
|
|
89
|
+
happens after Hammerfile evaluation, before dispatch. Shell-set vars
|
|
90
|
+
always win (`ENV[k] ||= v`). For full dotenv-gem features
|
|
91
|
+
(interpolation, multiline) keep `dotenv false` and call `Dotenv.load`
|
|
92
|
+
from a `before` hook instead.
|
|
87
93
|
* `load` / `load auto: true` / `load 'path/file.rb'` / `load 'glob/*.rb'` /
|
|
88
94
|
`load 'some/dir'` - pull in Hammerfile fragments from `*_hammer.rb`
|
|
89
95
|
files. Paths resolve relative to the caller's file. A directory
|
|
@@ -140,6 +146,23 @@ explicit ADR-level discussion. Keys:
|
|
|
140
146
|
* The root `Hammer` subclass holds `@commands` and `@namespaces`.
|
|
141
147
|
* `resolve('a:b:c')` walks `@namespaces['a'].namespaces['b']` and
|
|
142
148
|
finds command `c` in the last class.
|
|
149
|
+
* Each segment is fuzzy-matched: exact name/alt first, then prefix
|
|
150
|
+
(`b` -> `build`), then substring (`es` -> `test`). Per-scope: matching
|
|
151
|
+
only looks at this class's commands/namespaces, not globally across
|
|
152
|
+
the tree. Ambiguous fuzzy matches raise `Hammer::AmbiguousMatch`,
|
|
153
|
+
which `dispatch` catches and prints as
|
|
154
|
+
`multiple commands match 'b': bake, build`. Same fallback applies to
|
|
155
|
+
namespaces via `find_namespace`.
|
|
156
|
+
* `resolve` and `resolve_namespace` return a `canonical` colon-path
|
|
157
|
+
alongside the resolved class/command, so help banners and the
|
|
158
|
+
pre-run banner display the canonical name even when the user typed
|
|
159
|
+
a fuzzy prefix.
|
|
160
|
+
* Before a command's handler runs, `run_command` prints a gray
|
|
161
|
+
`> prog cmd --opt=val ARG` banner so it's visible which command was
|
|
162
|
+
actually picked when fuzzy matching kicks in. Only options that
|
|
163
|
+
differ from their default are listed; booleans render as `--flag`
|
|
164
|
+
/ `--no-flag`. Help paths (`-h`, bare namespace) short-circuit
|
|
165
|
+
before the banner.
|
|
143
166
|
* There is **no per-level dispatch**. A namespace is a container, not a
|
|
144
167
|
CLI of its own. Do not reintroduce `subclass.start(remaining_argv)`.
|
|
145
168
|
* `start(argv)` is a two-step pipeline: `split_chain(argv)` (private)
|
data/README.md
CHANGED
|
@@ -469,7 +469,7 @@ runs before every command in that scope (and its nested namespaces).
|
|
|
469
469
|
Hooks fire outer -> inner, then the command's handler:
|
|
470
470
|
|
|
471
471
|
```ruby
|
|
472
|
-
before {
|
|
472
|
+
before { hammer :env } # runs before every command
|
|
473
473
|
|
|
474
474
|
namespace :db do
|
|
475
475
|
before { hammer :env } # runs before every db:* command
|
|
@@ -479,6 +479,15 @@ namespace :db do
|
|
|
479
479
|
end
|
|
480
480
|
```
|
|
481
481
|
|
|
482
|
+
`.env` and `.env.local` next to the `Hammerfile` are loaded
|
|
483
|
+
automatically by the `hammer` binary - no `before` hook needed.
|
|
484
|
+
Shell-set vars are never overwritten, and `.env.local` overrides
|
|
485
|
+
`.env`. Put `dotenv false` at the top of the Hammerfile to opt out:
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
dotenv false # disable auto .env loading
|
|
489
|
+
```
|
|
490
|
+
|
|
482
491
|
`before` is intentionally not available inside `task` - the proc body
|
|
483
492
|
*is* the command body, just put the setup line at the top of the proc.
|
|
484
493
|
|
data/lib/hammer/builder.rb
CHANGED
|
@@ -21,6 +21,11 @@ class Hammer
|
|
|
21
21
|
@klass.before(&block)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Opt out of auto `.env` loading in `Hammer.cli`. Default is on.
|
|
25
|
+
def dotenv(flag = true)
|
|
26
|
+
@klass.dotenv(flag)
|
|
27
|
+
end
|
|
28
|
+
|
|
24
29
|
# Same surface as `Hammer.load`. Resolved relative to the file that
|
|
25
30
|
# called us, so `load auto: true` inside a Hammerfile picks up
|
|
26
31
|
# *_hammer.rb under the Hammerfile's directory.
|
|
@@ -28,5 +33,33 @@ class Hammer
|
|
|
28
33
|
anchor = Loader.caller_anchor(caller_locations(1, 1).first)
|
|
29
34
|
@klass.loader.load(anchor, paths, kwargs)
|
|
30
35
|
end
|
|
36
|
+
|
|
37
|
+
# instance_eval wrapper that also publishes the current Hammer target
|
|
38
|
+
# via Thread.current[:hammer_target]. That lets `*_hammer.rb` files
|
|
39
|
+
# pulled in through Ruby's own `require` (e.g. `Dir.require_all
|
|
40
|
+
# 'lib/tasks'` inside a Hammerfile) call `task`/`namespace`/`before`
|
|
41
|
+
# at top-level scope and have them register on the right target.
|
|
42
|
+
def evaluate(source = nil, path = nil, &block)
|
|
43
|
+
Hammer.with_target(@klass) do
|
|
44
|
+
block ? instance_eval(&block) : instance_eval(source, path)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Top-level Hammer DSL. Extended onto `main` (below) so any file
|
|
50
|
+
# `require`d from within a Hammerfile - where `self == main` - can
|
|
51
|
+
# still call `task`, `namespace`, and `before`. Delegates to whichever
|
|
52
|
+
# Hammer subclass is currently being evaluated.
|
|
53
|
+
module DSL
|
|
54
|
+
%i[task namespace before dotenv].each do |m|
|
|
55
|
+
define_method(m) do |*args, &block|
|
|
56
|
+
target = Thread.current[:hammer_target]
|
|
57
|
+
raise Hammer::Error, "`#{m}` called outside a Hammer context " \
|
|
58
|
+
'(Hammerfile / Hammer.run block / *_hammer.rb)' unless target
|
|
59
|
+
target.send(m, *args, &block)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
31
62
|
end
|
|
32
63
|
end
|
|
64
|
+
|
|
65
|
+
TOPLEVEL_BINDING.eval('self').extend(Hammer::DSL)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class Hammer
|
|
2
|
+
# Tiny built-in .env loader. Kept dependency-free on purpose - the
|
|
3
|
+
# gemspec advertises zero runtime deps.
|
|
4
|
+
#
|
|
5
|
+
# `Hammer.cli` calls `Dotenv.load(Dir.pwd)` after evaluating the
|
|
6
|
+
# Hammerfile (unless the Hammerfile said `dotenv false`), so vars
|
|
7
|
+
# from `.env` are available to every command handler.
|
|
8
|
+
#
|
|
9
|
+
# Semantics:
|
|
10
|
+
# * `.env` is loaded, then `.env.local` (latter overrides former).
|
|
11
|
+
# * Missing files are silently skipped - auto-load must not raise.
|
|
12
|
+
# * Existing `ENV[k]` always wins (`ENV[k] ||= v`); shell-set values
|
|
13
|
+
# are never clobbered.
|
|
14
|
+
# * Supported lines: `KEY=value`, `KEY="value"`, `KEY='value'`, optional
|
|
15
|
+
# leading `export `, `#` comments, blank lines.
|
|
16
|
+
# * NOT supported: `${VAR}` interpolation, multiline values, inline
|
|
17
|
+
# comments after a value. Reach for the `dotenv` gem from a `before`
|
|
18
|
+
# hook if you need those.
|
|
19
|
+
module Dotenv
|
|
20
|
+
FILES ||= %w[.env .env.local].freeze
|
|
21
|
+
|
|
22
|
+
def self.load(dir)
|
|
23
|
+
FILES.each do |name|
|
|
24
|
+
path = File.join(dir, name)
|
|
25
|
+
next unless File.file?(path)
|
|
26
|
+
parse(File.read(path)).each { |k, v| ENV[k] ||= v }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse(text)
|
|
31
|
+
out = {}
|
|
32
|
+
text.each_line do |line|
|
|
33
|
+
line = line.strip
|
|
34
|
+
next if line.empty? || line.start_with?('#')
|
|
35
|
+
line = line.sub(/\Aexport\s+/, '')
|
|
36
|
+
key, sep, val = line.partition('=')
|
|
37
|
+
next if sep.empty?
|
|
38
|
+
key = key.strip
|
|
39
|
+
next if key.empty?
|
|
40
|
+
val = val.strip
|
|
41
|
+
if (val.start_with?('"') && val.end_with?('"')) ||
|
|
42
|
+
(val.start_with?("'") && val.end_with?("'"))
|
|
43
|
+
val = val[1..-2]
|
|
44
|
+
end
|
|
45
|
+
out[key] = val
|
|
46
|
+
end
|
|
47
|
+
out
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/hammer/loader.rb
CHANGED
|
@@ -72,7 +72,7 @@ class Hammer
|
|
|
72
72
|
def load_one(abs_path)
|
|
73
73
|
return if @loaded[abs_path]
|
|
74
74
|
@loaded[abs_path] = true
|
|
75
|
-
Builder.new(@target).
|
|
75
|
+
Builder.new(@target).evaluate(File.read(abs_path), abs_path)
|
|
76
76
|
rescue Hammer::Error
|
|
77
77
|
raise
|
|
78
78
|
rescue StandardError => e
|
data/lib/hammer/option.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
class Hammer
|
|
2
2
|
# Single option/flag definition.
|
|
3
3
|
class Option
|
|
4
|
-
attr_reader :name, :type, :default, :desc, :aliases, :required
|
|
4
|
+
attr_reader :name, :type, :default, :desc, :aliases, :required, :placeholder
|
|
5
5
|
|
|
6
|
-
ALLOWED_KEYS ||= %i[type default desc alias req].freeze
|
|
6
|
+
ALLOWED_KEYS ||= %i[type default desc alias req placeholder].freeze
|
|
7
7
|
RESERVED_FLAGS ||= %w[-h --help].freeze
|
|
8
8
|
|
|
9
9
|
def initialize(name, **opts)
|
|
@@ -13,12 +13,15 @@ class Hammer
|
|
|
13
13
|
"unknown opt parameter(s) for :#{name}: #{bad.join(', ')}. " \
|
|
14
14
|
"allowed: #{ALLOWED_KEYS.join(', ')}"
|
|
15
15
|
end
|
|
16
|
-
@name
|
|
17
|
-
@type
|
|
18
|
-
@default
|
|
19
|
-
@desc
|
|
20
|
-
@aliases
|
|
21
|
-
@required
|
|
16
|
+
@name = name.to_sym
|
|
17
|
+
@type = opts[:type] || :string
|
|
18
|
+
@default = opts[:default]
|
|
19
|
+
@desc = opts[:desc]
|
|
20
|
+
@aliases = Array(opts[:alias]).map { |a| Option.normalize_alias(a) }
|
|
21
|
+
@required = opts[:req] ? true : false
|
|
22
|
+
# Custom usage placeholder, e.g. `placeholder: 't/f'` -> `--log t/f`.
|
|
23
|
+
# Falls back to uppercased name (`--log LOG`) when nil.
|
|
24
|
+
@placeholder = opts[:placeholder]
|
|
22
25
|
|
|
23
26
|
# Reserve -h / --help so every command supports them uniformly.
|
|
24
27
|
if RESERVED_FLAGS.include?(switch)
|
|
@@ -60,7 +63,7 @@ class Hammer
|
|
|
60
63
|
|
|
61
64
|
def usage
|
|
62
65
|
flag = switch
|
|
63
|
-
flag += " #{name.to_s.upcase}" unless boolean?
|
|
66
|
+
flag += " #{placeholder || name.to_s.upcase}" unless boolean?
|
|
64
67
|
line = aliases.empty? ? flag : "#{aliases.join(', ')}, #{flag}"
|
|
65
68
|
suffix = annotation
|
|
66
69
|
line = "#{line.ljust(28)} #{desc}" if desc
|
data/lib/lux-hammer.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'hammer/command'
|
|
|
5
5
|
require_relative 'hammer/loader'
|
|
6
6
|
require_relative 'hammer/builder'
|
|
7
7
|
require_relative 'hammer/command_builder'
|
|
8
|
+
require_relative 'hammer/dotenv'
|
|
8
9
|
|
|
9
10
|
# Thor-inspired tiny CLI builder.
|
|
10
11
|
#
|
|
@@ -40,7 +41,11 @@ require_relative 'hammer/command_builder'
|
|
|
40
41
|
class Hammer
|
|
41
42
|
include Shell
|
|
42
43
|
|
|
43
|
-
Error
|
|
44
|
+
Error ||= Class.new(StandardError)
|
|
45
|
+
AmbiguousMatch ||= Class.new(Error)
|
|
46
|
+
|
|
47
|
+
# Gem version, read once from the bundled .version file.
|
|
48
|
+
VERSION ||= File.read(File.expand_path('../.version', __dir__)).strip
|
|
44
49
|
|
|
45
50
|
class << self
|
|
46
51
|
def inherited(sub)
|
|
@@ -178,7 +183,7 @@ class Hammer
|
|
|
178
183
|
# "myapp ns:cmd" with the same prefix everywhere - and so the value
|
|
179
184
|
# captured pre-chdir (see `Hammer.cli`) survives into nested classes.
|
|
180
185
|
sub.instance_variable_set(:@program_name, program_name)
|
|
181
|
-
sub.class_eval(&block) if block
|
|
186
|
+
Hammer.with_target(sub) { sub.class_eval(&block) } if block
|
|
182
187
|
@namespaces[name.to_s] = sub
|
|
183
188
|
end
|
|
184
189
|
|
|
@@ -199,6 +204,18 @@ class Hammer
|
|
|
199
204
|
@before_hooks
|
|
200
205
|
end
|
|
201
206
|
|
|
207
|
+
# Toggle auto-loading of `.env` / `.env.local` for the `hammer`
|
|
208
|
+
# binary. Default is ON. Call `dotenv false` at the top of a
|
|
209
|
+
# Hammerfile to suppress. No-op for standalone `MyCli.start` -
|
|
210
|
+
# auto-load only fires from `Hammer.cli`.
|
|
211
|
+
def dotenv(flag = true)
|
|
212
|
+
@dotenv_enabled = flag
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def dotenv_enabled?
|
|
216
|
+
@dotenv_enabled != false
|
|
217
|
+
end
|
|
218
|
+
|
|
202
219
|
def parent
|
|
203
220
|
@parent
|
|
204
221
|
end
|
|
@@ -235,6 +252,18 @@ class Hammer
|
|
|
235
252
|
@loader ||= Loader.new(self)
|
|
236
253
|
end
|
|
237
254
|
|
|
255
|
+
# Push `klass` as the current Hammer target for the duration of the
|
|
256
|
+
# block. Top-level DSL methods (`task`, `namespace`, `before` - see
|
|
257
|
+
# `Hammer::DSL`) read this thread-local, so files `require`d from
|
|
258
|
+
# inside a Hammerfile register against the right target.
|
|
259
|
+
def with_target(klass)
|
|
260
|
+
prev = Thread.current[:hammer_target]
|
|
261
|
+
Thread.current[:hammer_target] = klass
|
|
262
|
+
yield
|
|
263
|
+
ensure
|
|
264
|
+
Thread.current[:hammer_target] = prev
|
|
265
|
+
end
|
|
266
|
+
|
|
238
267
|
# Load Hammerfile fragments and register their commands on this
|
|
239
268
|
# class. Rake-style: split a CLI across multiple files.
|
|
240
269
|
#
|
|
@@ -295,60 +324,133 @@ class Hammer
|
|
|
295
324
|
argv = argv.dup
|
|
296
325
|
name = argv.shift
|
|
297
326
|
|
|
298
|
-
if name.nil?
|
|
327
|
+
if name.nil?
|
|
328
|
+
# Bare invocation of the `hammer` binary: print a gem banner
|
|
329
|
+
# above the help so users can see which lux-hammer they have.
|
|
330
|
+
# Skipped for `-h` / `help` (those stay terse) and for user-
|
|
331
|
+
# built CLIs (their own program name, not lux-hammer).
|
|
332
|
+
if root.instance_variable_get(:@hammer_binary)
|
|
333
|
+
Shell.say "lux-hammer #{VERSION}"
|
|
334
|
+
Shell.say ''
|
|
335
|
+
end
|
|
336
|
+
return print_help
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
if name == 'help' || name == '-h' || name == '--help'
|
|
299
340
|
target = argv.shift
|
|
300
341
|
return print_help(target)
|
|
301
342
|
end
|
|
302
343
|
|
|
303
|
-
# Trailing colon ("db:") ->
|
|
304
|
-
# per-command help on every task. Bare ":" expands the root.
|
|
344
|
+
# Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
|
|
305
345
|
if name.end_with?(':') && name != ':'
|
|
306
346
|
bare = name.chomp(':')
|
|
307
|
-
ns = resolve_namespace(bare)
|
|
308
|
-
return print_namespace_help(
|
|
347
|
+
ns, canonical = resolve_namespace(bare)
|
|
348
|
+
return print_namespace_help(canonical, ns) if ns
|
|
309
349
|
Shell.print_error("unknown namespace: #{bare}")
|
|
310
350
|
print_help
|
|
311
351
|
exit 1
|
|
312
352
|
elsif name == ':'
|
|
313
|
-
return print_help
|
|
353
|
+
return print_help
|
|
314
354
|
end
|
|
315
355
|
|
|
316
|
-
cmd, owner = resolve(name)
|
|
317
|
-
return owner.run_command(cmd, argv, full:
|
|
356
|
+
cmd, owner, canonical = resolve(name)
|
|
357
|
+
return owner.run_command(cmd, argv, full: canonical) if cmd
|
|
318
358
|
|
|
319
|
-
ns = resolve_namespace(name)
|
|
320
|
-
return print_namespace_help(
|
|
359
|
+
ns, canonical = resolve_namespace(name)
|
|
360
|
+
return print_namespace_help(canonical, ns) if ns
|
|
321
361
|
|
|
322
362
|
Shell.print_error("unknown command: #{name}")
|
|
323
363
|
print_help
|
|
324
364
|
exit 1
|
|
365
|
+
rescue AmbiguousMatch => e
|
|
366
|
+
Shell.print_error(e.message)
|
|
367
|
+
exit 1
|
|
325
368
|
end
|
|
326
369
|
|
|
327
370
|
public
|
|
328
371
|
|
|
329
|
-
# Find a command by canonical name or alt within this class.
|
|
372
|
+
# Find a command by canonical name or alt within this class. Falls
|
|
373
|
+
# back to fuzzy match (prefix first, then substring) when no exact
|
|
374
|
+
# hit. Raises AmbiguousMatch if the fuzzy pass matches more than one.
|
|
330
375
|
def find_command(name)
|
|
331
|
-
|
|
376
|
+
name = name.to_s
|
|
377
|
+
exact = commands[name] || commands.values.find { |c| c.matches?(name) }
|
|
378
|
+
return exact if exact
|
|
379
|
+
fuzzy_pick(name, commands.values, 'command') { |c| [c.name, *c.alts] }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Find a namespace by name within this class. Same fuzzy fallback
|
|
383
|
+
# as find_command.
|
|
384
|
+
def find_namespace(name)
|
|
385
|
+
name = name.to_s
|
|
386
|
+
return namespaces[name] if namespaces.key?(name)
|
|
387
|
+
pair = fuzzy_pick(name, namespaces.to_a, 'namespace') { |p| [p.first] }
|
|
388
|
+
pair&.last
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Returns the command at the parent that shares its name with this
|
|
392
|
+
# namespace. E.g. for path "gem:version" returns the `version` command
|
|
393
|
+
# in the `gem` namespace (if defined), so `gem:version:` listings can
|
|
394
|
+
# include `gem:version` itself at the top.
|
|
395
|
+
def find_namespace_sibling(canonical)
|
|
396
|
+
parts = canonical.to_s.split(':')
|
|
397
|
+
return nil if parts.empty?
|
|
398
|
+
parent = self
|
|
399
|
+
parts[0..-2].each do |seg|
|
|
400
|
+
parent = parent.namespaces[seg] or return nil
|
|
401
|
+
end
|
|
402
|
+
parent.commands[parts.last]
|
|
332
403
|
end
|
|
333
404
|
|
|
334
|
-
# Walk "ns1:ns2:cmd" -> [command, owning_class
|
|
335
|
-
# if any segment is missing or the final
|
|
405
|
+
# Walk "ns1:ns2:cmd" -> [command, owning_class, canonical_path].
|
|
406
|
+
# Returns [nil, nil, nil] if any segment is missing or the final
|
|
407
|
+
# segment isn't a command. canonical_path uses the canonical name
|
|
408
|
+
# of every segment (so a fuzzy `b` resolves to `build` in the path).
|
|
336
409
|
def resolve(path)
|
|
337
410
|
parts = path.to_s.split(':')
|
|
338
411
|
klass = self
|
|
412
|
+
canonical = []
|
|
339
413
|
parts[0..-2].each do |ns|
|
|
340
|
-
|
|
414
|
+
sub = klass.find_namespace(ns) or return [nil, nil, nil]
|
|
415
|
+
canonical << klass.namespaces.key(sub)
|
|
416
|
+
klass = sub
|
|
341
417
|
end
|
|
342
418
|
cmd = klass.find_command(parts.last)
|
|
343
|
-
|
|
419
|
+
return [nil, nil, nil] unless cmd
|
|
420
|
+
canonical << cmd.name
|
|
421
|
+
[cmd, klass, canonical.join(':')]
|
|
344
422
|
end
|
|
345
423
|
|
|
346
|
-
# Walk "ns1:ns2" ->
|
|
424
|
+
# Walk "ns1:ns2" -> [namespace_class, canonical_path].
|
|
425
|
+
# Returns [nil, nil] if any segment is missing.
|
|
347
426
|
def resolve_namespace(path)
|
|
348
427
|
parts = path.to_s.split(':')
|
|
349
428
|
klass = self
|
|
350
|
-
|
|
351
|
-
|
|
429
|
+
canonical = []
|
|
430
|
+
parts.each do |ns|
|
|
431
|
+
sub = klass.find_namespace(ns) or return [nil, nil]
|
|
432
|
+
canonical << klass.namespaces.key(sub)
|
|
433
|
+
klass = sub
|
|
434
|
+
end
|
|
435
|
+
[klass, canonical.join(':')]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Shared fuzzy matcher used by find_command and find_namespace.
|
|
439
|
+
# The block returns the strings to match against for each item
|
|
440
|
+
# (canonical name plus alts for commands, just the key for namespaces).
|
|
441
|
+
# Tries prefix match first, then substring; raises AmbiguousMatch
|
|
442
|
+
# when either pass hits more than one item.
|
|
443
|
+
def fuzzy_pick(name, items, kind, &keys_for)
|
|
444
|
+
[:start_with?, :include?].each do |op|
|
|
445
|
+
matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
|
|
446
|
+
next if matches.empty?
|
|
447
|
+
if matches.size > 1
|
|
448
|
+
labels = matches.map { |m| keys_for.call(m).first }.sort
|
|
449
|
+
raise AmbiguousMatch, "multiple #{kind}s match '#{name}': #{labels.join(', ')}"
|
|
450
|
+
end
|
|
451
|
+
return matches.first
|
|
452
|
+
end
|
|
453
|
+
nil
|
|
352
454
|
end
|
|
353
455
|
|
|
354
456
|
# Programmatic dispatch by name. Useful for scripting and tests.
|
|
@@ -394,6 +496,7 @@ class Hammer
|
|
|
394
496
|
|
|
395
497
|
positional, opts = Parser.new(cmd.options).parse(argv)
|
|
396
498
|
opts[:args] = positional
|
|
499
|
+
print_run_banner(cmd, full || cmd.name, positional, opts)
|
|
397
500
|
instance = new
|
|
398
501
|
run_before_hooks(instance, opts)
|
|
399
502
|
run_needs(cmd)
|
|
@@ -409,6 +512,25 @@ class Hammer
|
|
|
409
512
|
exit 1
|
|
410
513
|
end
|
|
411
514
|
|
|
515
|
+
# Print a gray "> prog cmd --opt=val ARG" banner before a command
|
|
516
|
+
# runs. Helps see what was actually picked when fuzzy matching
|
|
517
|
+
# resolved a partial name. Only opts that differ from their default
|
|
518
|
+
# are shown; booleans render as `--flag` / `--no-flag`.
|
|
519
|
+
def print_run_banner(cmd, full, positional, opts)
|
|
520
|
+
parts = ["#{program_name} #{full}"]
|
|
521
|
+
cmd.options.each do |o|
|
|
522
|
+
val = opts[o.name]
|
|
523
|
+
next if val.nil? || val == o.default
|
|
524
|
+
if o.boolean?
|
|
525
|
+
parts << (val ? "--#{o.name}" : "--no-#{o.name}")
|
|
526
|
+
else
|
|
527
|
+
parts << "--#{o.name}=#{val}"
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
parts.concat(positional)
|
|
531
|
+
Shell.say "> #{parts.join(' ')}", :gray
|
|
532
|
+
end
|
|
533
|
+
|
|
412
534
|
# Fire `before` hooks from root down through the namespace chain.
|
|
413
535
|
# Each class's hooks fire at most once per top-level `start`, so
|
|
414
536
|
# prereqs dispatched via `needs` won't re-trigger them.
|
|
@@ -444,18 +566,18 @@ class Hammer
|
|
|
444
566
|
|
|
445
567
|
def print_help(target = nil, full: false)
|
|
446
568
|
if target
|
|
447
|
-
# `help ns:` is equivalent to `ns:` -
|
|
569
|
+
# `help ns:` is equivalent to `ns:` - namespace listing.
|
|
448
570
|
if target.end_with?(':') && target != ':'
|
|
449
571
|
bare = target.chomp(':')
|
|
450
|
-
ns = resolve_namespace(bare)
|
|
451
|
-
return print_namespace_help(
|
|
572
|
+
ns, canonical = resolve_namespace(bare)
|
|
573
|
+
return print_namespace_help(canonical, ns) if ns
|
|
452
574
|
Shell.print_error("unknown: #{target}")
|
|
453
575
|
return
|
|
454
576
|
end
|
|
455
|
-
cmd, _ = resolve(target)
|
|
456
|
-
return print_command_help(cmd,
|
|
457
|
-
ns = resolve_namespace(target)
|
|
458
|
-
return print_namespace_help(
|
|
577
|
+
cmd, _, canonical = resolve(target)
|
|
578
|
+
return print_command_help(cmd, canonical) if cmd
|
|
579
|
+
ns, canonical = resolve_namespace(target)
|
|
580
|
+
return print_namespace_help(canonical, ns, full: full) if ns
|
|
459
581
|
Shell.print_error("unknown: #{target}")
|
|
460
582
|
return
|
|
461
583
|
end
|
|
@@ -473,11 +595,15 @@ class Hammer
|
|
|
473
595
|
|
|
474
596
|
def print_namespace_help(prefix, ns, full: false)
|
|
475
597
|
Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
598
|
+
rows = []
|
|
599
|
+
sibling = find_namespace_sibling(prefix)
|
|
600
|
+
rows << [prefix, sibling] if sibling && !sibling.desc.empty?
|
|
601
|
+
ns.each_command(prefix) { |path, c| rows << [path, c] unless c.desc.empty? }
|
|
602
|
+
unless rows.empty?
|
|
479
603
|
Shell.say ''
|
|
480
|
-
|
|
604
|
+
Shell.say 'Commands:', :yellow
|
|
605
|
+
width = rows.map { |path, _| path.length }.max
|
|
606
|
+
emit_rows(rows.sort_by { |path, _| [path.count(':'), path] }, width)
|
|
481
607
|
end
|
|
482
608
|
print_global_flags
|
|
483
609
|
print_footer
|
|
@@ -620,11 +746,11 @@ class Hammer
|
|
|
620
746
|
def self.run(argv = ARGV, &block)
|
|
621
747
|
klass = Class.new(Hammer)
|
|
622
748
|
if block
|
|
623
|
-
Builder.new(klass).
|
|
749
|
+
Builder.new(klass).evaluate(&block)
|
|
624
750
|
else
|
|
625
751
|
hf = File.join(Dir.pwd, 'Hammerfile')
|
|
626
752
|
if File.file?(hf)
|
|
627
|
-
Builder.new(klass).
|
|
753
|
+
Builder.new(klass).evaluate(File.read(hf), hf)
|
|
628
754
|
else
|
|
629
755
|
klass.loader.load(Dir.pwd, [], auto: true)
|
|
630
756
|
end
|
|
@@ -699,7 +825,11 @@ class Hammer
|
|
|
699
825
|
# chdir into the Hammerfile's directory for the entire run so commands
|
|
700
826
|
# operate on the project root (Rake-style).
|
|
701
827
|
Dir.chdir(File.dirname(path))
|
|
702
|
-
Builder.new(klass).
|
|
828
|
+
Builder.new(klass).evaluate(File.read(path), path)
|
|
829
|
+
# Auto-load `.env` / `.env.local` after eval so a top-level
|
|
830
|
+
# `dotenv false` in the Hammerfile can suppress it. Trade-off: vars
|
|
831
|
+
# are NOT visible during Hammerfile evaluation, only inside handlers.
|
|
832
|
+
Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
|
|
703
833
|
klass.start(argv)
|
|
704
834
|
end
|
|
705
835
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- "./lib/hammer/builder.rb"
|
|
39
39
|
- "./lib/hammer/command.rb"
|
|
40
40
|
- "./lib/hammer/command_builder.rb"
|
|
41
|
+
- "./lib/hammer/dotenv.rb"
|
|
41
42
|
- "./lib/hammer/loader.rb"
|
|
42
43
|
- "./lib/hammer/option.rb"
|
|
43
44
|
- "./lib/hammer/parser.rb"
|
|
@@ -62,7 +63,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
62
63
|
- !ruby/object:Gem::Version
|
|
63
64
|
version: '0'
|
|
64
65
|
requirements: []
|
|
65
|
-
rubygems_version: 4.0.
|
|
66
|
+
rubygems_version: 4.0.11
|
|
66
67
|
specification_version: 4
|
|
67
68
|
summary: Thor-inspired tiny CLI builder
|
|
68
69
|
test_files: []
|