lux-hammer 0.1.1 → 0.2.1
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/README.md +224 -46
- data/lib/hammer/builder.rb +6 -0
- data/lib/hammer/command.rb +6 -1
- data/lib/hammer/command_builder.rb +4 -0
- data/lib/hammer/loader.rb +10 -10
- data/lib/hammer/shell.rb +104 -8
- data/lib/lux-hammer.rb +107 -10
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 17ad95331316a0cd95554605d58b2a7dce64f9db9d6adc997b34cf5f3b1e2b1a
|
|
4
|
+
data.tar.gz: 633ad47b777e68b848f11552e5345d5b468e01a0fa27a273dc6551aac7d18af1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0568fccc6f143b0671166a08c1853a3f682bf2c4c5488791fa7d46e1c4d8b3cc311ae29a016e9cf9a93f2cc31a577a551af72f0f4754cc9e9bd3adb66c5f6731'
|
|
7
|
+
data.tar.gz: bf5e04d1e37e50f138c7c14079b889018f85bda195dad8676df4f634a37fe62414a1511a451aacb08f1e0860651426d8a7bca78cf3bad35b1fbf3dc137423438
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.1
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Create a `Hammerfile` in your project root:
|
|
|
27
27
|
define :hello do
|
|
28
28
|
desc 'say hi'
|
|
29
29
|
proc do |opts|
|
|
30
|
-
say "hello #{opts[:args].first || 'world'}"
|
|
30
|
+
say.green "hello #{opts[:args].first || 'world'}"
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
```
|
|
@@ -143,7 +143,7 @@ define :build do
|
|
|
143
143
|
opt :env, default: 'dev'
|
|
144
144
|
|
|
145
145
|
proc do |opts|
|
|
146
|
-
say "building #{opts[:env]}"
|
|
146
|
+
say.green "building #{opts[:env]}"
|
|
147
147
|
end
|
|
148
148
|
end
|
|
149
149
|
```
|
|
@@ -154,13 +154,11 @@ For when you'd rather write a Ruby method:
|
|
|
154
154
|
|
|
155
155
|
```ruby
|
|
156
156
|
class MyCli < Hammer
|
|
157
|
-
program_name 'mycli'
|
|
158
|
-
|
|
159
157
|
desc 'Build the project'
|
|
160
158
|
opt :verbose, type: :boolean, alias: :v
|
|
161
159
|
opt :env, default: 'dev'
|
|
162
160
|
def build(opts)
|
|
163
|
-
say "building #{opts[:env]}"
|
|
161
|
+
say.green "building #{opts[:env]}"
|
|
164
162
|
end
|
|
165
163
|
|
|
166
164
|
desc 'Ping with no opts'
|
|
@@ -178,22 +176,6 @@ without opts; methods that take an argument receive the opts hash.
|
|
|
178
176
|
|
|
179
177
|
Both styles can coexist in the same class.
|
|
180
178
|
|
|
181
|
-
## Program name
|
|
182
|
-
|
|
183
|
-
`program_name 'foo'` (alias `program 'foo'`) sets the name shown in
|
|
184
|
-
help/usage output. It is **optional**. When omitted, the default is:
|
|
185
|
-
|
|
186
|
-
* the invocation path **relative to cwd** if the script lives inside
|
|
187
|
-
the current working directory (e.g. `bin/foo` when invoked from the
|
|
188
|
-
project root as `./bin/foo` or `bin/foo`), or
|
|
189
|
-
* the **basename** of `$PROGRAM_NAME` otherwise (e.g. `lux` for a
|
|
190
|
-
globally installed bin in `PATH`).
|
|
191
|
-
|
|
192
|
-
So a project-local wrapper at `bin/foo` shows up in help as
|
|
193
|
-
`Usage: bin/foo COMMAND [ARGS]`, while the same library invoked
|
|
194
|
-
through a globally installed `lux` shim shows `Usage: lux ...`. Set
|
|
195
|
-
`program 'whatever'` explicitly to override.
|
|
196
|
-
|
|
197
179
|
## Options (`opt`)
|
|
198
180
|
|
|
199
181
|
### Declaration
|
|
@@ -434,6 +416,84 @@ hammer db:migrate -h # per-command help
|
|
|
434
416
|
Namespaces nest to any depth. There is no per-level dispatch - the root
|
|
435
417
|
parses the whole colon path and walks the namespace tree.
|
|
436
418
|
|
|
419
|
+
## Pre-hooks (`before`)
|
|
420
|
+
|
|
421
|
+
A `before do ... end` block at the root scope or inside a `namespace`
|
|
422
|
+
runs before every command in that scope (and its nested namespaces).
|
|
423
|
+
Hooks fire outer -> inner, then the command's handler:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
before { Dotenv.load } # runs before every command
|
|
427
|
+
|
|
428
|
+
namespace :db do
|
|
429
|
+
before { hammer_env } # runs before every db:* command
|
|
430
|
+
define :migrate do
|
|
431
|
+
proc { |opts| ... } # no boilerplate require inside
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
`before` is intentionally not available inside `define` - the proc body
|
|
437
|
+
*is* the command body, just put the setup line at the top of the proc.
|
|
438
|
+
|
|
439
|
+
Pairs naturally with hidden commands (next section): keep `:env` /
|
|
440
|
+
`:app` setup as undocumented helpers and pull them in via `before`.
|
|
441
|
+
|
|
442
|
+
## Hidden commands (no `desc`)
|
|
443
|
+
|
|
444
|
+
A command declared without a `desc` is **hidden from help listings**
|
|
445
|
+
but stays fully dispatchable and `hammer_*`-callable:
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
define :env do
|
|
449
|
+
proc { |_| require './config/env' } # no desc -> hidden
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
namespace :db do
|
|
453
|
+
before { hammer_env } # call it from a hook
|
|
454
|
+
define :migrate do
|
|
455
|
+
desc 'Run migrations'
|
|
456
|
+
proc { |_| ... }
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
`hammer` and `hammer db` won't list `env`, but `hammer env`,
|
|
462
|
+
`hammer_env` from another proc, and `before { hammer_env }` all work.
|
|
463
|
+
|
|
464
|
+
## Prereqs (`needs`)
|
|
465
|
+
|
|
466
|
+
Declare commands that must run before this one (Rake-style task deps):
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
define :env do
|
|
470
|
+
proc { |_| require './config/env' } # hidden helper
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
define :app do
|
|
474
|
+
needs :env # runs `env` first
|
|
475
|
+
desc 'start the app'
|
|
476
|
+
proc { |opts| App.start }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
define :deploy do
|
|
480
|
+
needs :env, :build # multiple prereqs, in order
|
|
481
|
+
proc { |opts| ... }
|
|
482
|
+
end
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Prereq names are colon paths resolved against the root class - same
|
|
486
|
+
lookup as `hammer_*`. Use `needs 'db:env'` to depend on a namespaced
|
|
487
|
+
command.
|
|
488
|
+
|
|
489
|
+
Each prereq fires **at most once per top-level invocation**, so if
|
|
490
|
+
`:app` needs `:env` and `:build` also needs `:env`, `:env` still runs
|
|
491
|
+
only once. Prereqs run with default options (no argv passed through).
|
|
492
|
+
|
|
493
|
+
`needs` vs `before`:
|
|
494
|
+
* `before { hammer_env }` - fires for *every* command in a scope.
|
|
495
|
+
* `needs :env` - declared per command, deduped across the call chain.
|
|
496
|
+
|
|
437
497
|
## Command aliases (`alt`)
|
|
438
498
|
|
|
439
499
|
`alt :short_name` (or several) registers extra names for a command:
|
|
@@ -459,7 +519,7 @@ define :deploy do
|
|
|
459
519
|
proc do |opts|
|
|
460
520
|
hammer_build(env: 'prod', verbose: true)
|
|
461
521
|
hammer_db_migrate
|
|
462
|
-
say 'deployed'
|
|
522
|
+
say.green 'deployed'
|
|
463
523
|
end
|
|
464
524
|
end
|
|
465
525
|
```
|
|
@@ -481,19 +541,111 @@ class level, useful for tests and scripting.
|
|
|
481
541
|
|
|
482
542
|
## Shell helpers
|
|
483
543
|
|
|
484
|
-
|
|
544
|
+
These are mixed into every handler (and also live on `Hammer::Shell` for
|
|
545
|
+
direct use).
|
|
546
|
+
|
|
547
|
+
### `say` - print a line
|
|
548
|
+
|
|
549
|
+
```ruby
|
|
550
|
+
say 'plain output' # no color
|
|
551
|
+
say.green 'ok' # color via proxy (preferred)
|
|
552
|
+
say.cyan "env=#{env}" # interpolation works the same
|
|
553
|
+
say 'equivalent', :green # two-arg form is still supported
|
|
554
|
+
say '' # blank line
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
`say` with no args returns a tiny proxy that exposes one method per
|
|
558
|
+
color: `say.red 'x'` is just `say('x', :red)`. The proxy form reads
|
|
559
|
+
better when colors are the common case. Use `say ''` for an explicit
|
|
560
|
+
blank line.
|
|
561
|
+
|
|
562
|
+
### `String#color` - paint a string without printing
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
label = 'OK'.color(:green)
|
|
566
|
+
puts "[#{label}] done" # embed colored chunks anywhere
|
|
567
|
+
|
|
568
|
+
Hammer::Shell.paint('x', :red) # the underlying primitive `say` uses
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### Colors
|
|
572
|
+
|
|
573
|
+
Valid names: `:black :red :green :yellow :blue :magenta :cyan :white :gray`.
|
|
574
|
+
Unknown colors (in `say`, `say.<color>`, `paint`, or `String#color`)
|
|
575
|
+
raise `Hammer::Error` listing the valid names - even when colors are
|
|
576
|
+
disabled, so typos fail loudly in CI.
|
|
577
|
+
|
|
578
|
+
Colors are auto-disabled when stdout isn't a TTY or when `NO_COLOR` is
|
|
579
|
+
set. Force on/off programmatically:
|
|
580
|
+
|
|
581
|
+
```ruby
|
|
582
|
+
Hammer::Shell.color!(true) # force ANSI on
|
|
583
|
+
Hammer::Shell.color!(false) # force off
|
|
584
|
+
Hammer::Shell.color? # current state (bool)
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### `error` - controlled failure
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
error 'config file missing' unless File.exist?('config.yml')
|
|
591
|
+
# -> dispatcher prints "[error] config file missing" in red, exits 1
|
|
592
|
+
# No backtrace, no per-command help spam.
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Internally it just raises `Hammer::Error`; the dispatcher catches it.
|
|
596
|
+
|
|
597
|
+
### `ask` - read one line from stdin
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
name = ask 'your name' # required-style prompt
|
|
601
|
+
env = ask 'env', default: 'dev' # blank input -> "dev"
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
The prompt is shown in cyan with the default in brackets when present.
|
|
605
|
+
Returns the typed line (chomped) or the default on a blank line.
|
|
606
|
+
|
|
607
|
+
### `yes?` - y/N confirmation
|
|
485
608
|
|
|
486
609
|
```ruby
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
error 'something broke' # prints + exits 1 (raises Hammer::Error)
|
|
490
|
-
name = ask 'name', default: 'world'
|
|
491
|
-
exit 0 unless yes? 'continue?'
|
|
492
|
-
sh 'bundle install' # echoes "$ bundle install", aborts on non-zero
|
|
610
|
+
exit 0 unless yes? 'continue?' # anything starting with y/Y -> true
|
|
611
|
+
# blank, n, anything else -> false
|
|
493
612
|
```
|
|
494
613
|
|
|
495
|
-
|
|
496
|
-
|
|
614
|
+
### `choose` - arrow-key picker
|
|
615
|
+
|
|
616
|
+
```ruby
|
|
617
|
+
envs = %w[dev staging prod]
|
|
618
|
+
idx = choose 'Pick env', envs
|
|
619
|
+
say.green "deploying to #{envs[idx]}" if idx
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
While the picker is up: `j`/`k` or `↑`/`↓` to move, `Enter` to confirm,
|
|
623
|
+
`q` / `ESC` / Ctrl-C to cancel. Returns the integer index of the
|
|
624
|
+
selected item, or `nil` on cancel.
|
|
625
|
+
|
|
626
|
+
When stdin isn't a TTY (pipes, redirected scripts, tests), `choose`
|
|
627
|
+
falls back to a numbered prompt: it prints each item with a number and
|
|
628
|
+
reads a line - same return contract, so calling code doesn't change.
|
|
629
|
+
|
|
630
|
+
```
|
|
631
|
+
$ echo 2 | mycli some-cmd
|
|
632
|
+
Pick env
|
|
633
|
+
1) dev
|
|
634
|
+
2) staging
|
|
635
|
+
3) prod
|
|
636
|
+
select [1-3]:
|
|
637
|
+
# idx -> 1 (zero-based)
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### `sh` - run a shell command, abort on failure
|
|
641
|
+
|
|
642
|
+
```ruby
|
|
643
|
+
sh 'bundle install' # echoes "$ bundle install" in gray
|
|
644
|
+
sh "git tag v#{version}" # raises Hammer::Error on non-zero
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
Returns `true` on success. On non-zero exit it raises `Hammer::Error`,
|
|
648
|
+
which the dispatcher turns into `[error] command failed: ...` + exit 1.
|
|
497
649
|
|
|
498
650
|
## Splitting across files (`load`)
|
|
499
651
|
|
|
@@ -502,7 +654,6 @@ in any file ending in `_hammer.rb` and pull them in with `load`:
|
|
|
502
654
|
|
|
503
655
|
```ruby
|
|
504
656
|
# Hammerfile
|
|
505
|
-
program 'demo'
|
|
506
657
|
load auto: true # recursive scan for *_hammer.rb from here
|
|
507
658
|
```
|
|
508
659
|
|
|
@@ -512,7 +663,7 @@ namespace :db do
|
|
|
512
663
|
define :migrate do
|
|
513
664
|
desc 'Run pending migrations'
|
|
514
665
|
opt :pretend, type: :boolean, alias: :p
|
|
515
|
-
proc { |o| say "migrating pretend=#{o[:pretend].inspect}"
|
|
666
|
+
proc { |o| say.green "migrating pretend=#{o[:pretend].inspect}" }
|
|
516
667
|
end
|
|
517
668
|
end
|
|
518
669
|
```
|
|
@@ -523,7 +674,7 @@ define :deploy do
|
|
|
523
674
|
desc 'Deploy to prod'
|
|
524
675
|
proc do |_|
|
|
525
676
|
hammer_db_migrate # cross-file invocation just works
|
|
526
|
-
say 'deployed'
|
|
677
|
+
say.cyan 'deployed'
|
|
527
678
|
end
|
|
528
679
|
end
|
|
529
680
|
```
|
|
@@ -536,12 +687,18 @@ load auto: true # recursive scan for *_hammer.rb under caller dir
|
|
|
536
687
|
load 'tasks/db_hammer.rb' # one file (path relative to caller)
|
|
537
688
|
load 'tasks/*_hammer.rb' # glob
|
|
538
689
|
load 'a.rb', 'b.rb' # several explicit paths
|
|
690
|
+
load 'tasks' # directory -> recursive scan under it (empty OK)
|
|
539
691
|
```
|
|
540
692
|
|
|
541
693
|
Paths resolve relative to the file calling `load`, not cwd. Inside a
|
|
542
694
|
`Hammerfile` that means "relative to the Hammerfile"; inside a class
|
|
543
695
|
body it means "relative to that file".
|
|
544
696
|
|
|
697
|
+
A directory argument is the explicit-anchor twin of `load auto: true` -
|
|
698
|
+
walks the directory for `*_hammer.rb` with the same skip rules. Useful
|
|
699
|
+
when you want to pull fragments from a known sibling tree without
|
|
700
|
+
making it the caller's dir.
|
|
701
|
+
|
|
545
702
|
### What's skipped
|
|
546
703
|
|
|
547
704
|
Auto-discovery walks recursively but skips `.git`, `.bundle`,
|
|
@@ -575,26 +732,49 @@ Same shape as a Hammerfile, just inline:
|
|
|
575
732
|
require 'lux-hammer'
|
|
576
733
|
|
|
577
734
|
Hammer.run(ARGV) do
|
|
578
|
-
program 'inline'
|
|
579
|
-
|
|
580
735
|
define :hello do
|
|
581
736
|
desc 'say hi'
|
|
582
737
|
opt :loud, type: :boolean, alias: :l
|
|
583
738
|
proc do |opts|
|
|
584
739
|
msg = "hello #{opts[:args].first || 'world'}"
|
|
585
740
|
msg = msg.upcase if opts[:loud]
|
|
586
|
-
say msg
|
|
741
|
+
say.cyan msg
|
|
587
742
|
end
|
|
588
743
|
end
|
|
589
744
|
end
|
|
590
745
|
```
|
|
591
746
|
|
|
592
|
-
|
|
747
|
+
### `Hammer.run` without a block
|
|
748
|
+
|
|
749
|
+
If you call `Hammer.run(ARGV)` with no block, it does the obvious thing
|
|
750
|
+
relative to `Dir.pwd`:
|
|
751
|
+
|
|
752
|
+
* If `./Hammerfile` exists, it's evaluated as the block DSL.
|
|
753
|
+
* Otherwise, `*_hammer.rb` files under `Dir.pwd` are auto-discovered
|
|
754
|
+
(same walk + skip rules as `load auto: true`).
|
|
755
|
+
|
|
756
|
+
Either way ARGV is dispatched against the resulting CLI. Useful for a
|
|
757
|
+
one-line custom bin:
|
|
593
758
|
|
|
594
759
|
```ruby
|
|
595
|
-
|
|
596
|
-
|
|
760
|
+
#!/usr/bin/env ruby
|
|
761
|
+
require 'lux-hammer'
|
|
762
|
+
Hammer.run ARGV
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
For more control - e.g. loading a Hammerfile from a fixed path *and*
|
|
766
|
+
auto-discovering from cwd - pass a block and use `load` explicitly:
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
Hammer.run ARGV do
|
|
770
|
+
load File.join(MY_ROOT, 'Hammerfile')
|
|
771
|
+
load Dir.pwd if Dir.pwd != MY_ROOT
|
|
772
|
+
end
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
## Complete example (every feature)
|
|
597
776
|
|
|
777
|
+
```ruby
|
|
598
778
|
# Simple top-level command
|
|
599
779
|
define :build do
|
|
600
780
|
desc 'Build the project'
|
|
@@ -605,7 +785,7 @@ define :build do
|
|
|
605
785
|
|
|
606
786
|
proc do |opts|
|
|
607
787
|
target = opts[:args].first || opts[:env]
|
|
608
|
-
say "building #{target}"
|
|
788
|
+
say.green "building #{target}"
|
|
609
789
|
say ' verbose on' if opts[:verbose]
|
|
610
790
|
end
|
|
611
791
|
end
|
|
@@ -620,7 +800,7 @@ define :deploy do
|
|
|
620
800
|
proc do |opts|
|
|
621
801
|
hammer_build(env: 'prod')
|
|
622
802
|
exit 0 unless yes? "deploy to #{opts[:url]}?" unless opts[:force]
|
|
623
|
-
say "deploying to #{opts[:url]}"
|
|
803
|
+
say.yellow "deploying to #{opts[:url]}"
|
|
624
804
|
end
|
|
625
805
|
end
|
|
626
806
|
|
|
@@ -634,7 +814,7 @@ namespace :db do
|
|
|
634
814
|
|
|
635
815
|
proc do |opts|
|
|
636
816
|
step = opts[:args].first || 'all'
|
|
637
|
-
say "migrating #{step} pretend=#{opts[:pretend].inspect}"
|
|
817
|
+
say.green "migrating #{step} pretend=#{opts[:pretend].inspect}"
|
|
638
818
|
end
|
|
639
819
|
end
|
|
640
820
|
|
|
@@ -645,7 +825,7 @@ namespace :db do
|
|
|
645
825
|
opt :limit, type: :integer, default: 100
|
|
646
826
|
|
|
647
827
|
proc do |opts|
|
|
648
|
-
say "users role=#{opts[:role]} limit=#{opts[:limit]}"
|
|
828
|
+
say.cyan "users role=#{opts[:role]} limit=#{opts[:limit]}"
|
|
649
829
|
end
|
|
650
830
|
end
|
|
651
831
|
|
|
@@ -719,8 +899,6 @@ directly. Useful for embedding or testing:
|
|
|
719
899
|
require 'lux-hammer'
|
|
720
900
|
|
|
721
901
|
class MyCli < Hammer
|
|
722
|
-
program_name 'mycli'
|
|
723
|
-
|
|
724
902
|
define :greet do
|
|
725
903
|
opt :loud, type: :boolean
|
|
726
904
|
proc do |opts|
|
data/lib/hammer/builder.rb
CHANGED
|
@@ -19,6 +19,12 @@ class Hammer
|
|
|
19
19
|
@klass.namespace(name, &block)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Per-target / per-namespace pre-hook. Same semantics as
|
|
23
|
+
# `Hammer.before` at the class level - see lux-hammer.rb.
|
|
24
|
+
def before(&block)
|
|
25
|
+
@klass.before(&block)
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
# Same surface as `Hammer.load`. Resolved relative to the file that
|
|
23
29
|
# called us, so `load auto: true` inside a Hammerfile picks up
|
|
24
30
|
# *_hammer.rb under the Hammerfile's directory.
|
data/lib/hammer/command.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
class Hammer
|
|
2
2
|
# A single registered command on a Hammer class.
|
|
3
3
|
class Command
|
|
4
|
-
attr_reader :name, :desc, :options, :examples, :alts
|
|
4
|
+
attr_reader :name, :desc, :options, :examples, :alts, :needs
|
|
5
5
|
attr_accessor :handler
|
|
6
6
|
|
|
7
7
|
def initialize(name:, desc: '', handler: nil)
|
|
@@ -11,6 +11,7 @@ class Hammer
|
|
|
11
11
|
@options = []
|
|
12
12
|
@examples = []
|
|
13
13
|
@alts = []
|
|
14
|
+
@needs = []
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# First line of `desc`, used in the flat command listing.
|
|
@@ -30,6 +31,10 @@ class Hammer
|
|
|
30
31
|
@alts << name.to_s
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
def add_need(name)
|
|
35
|
+
@needs << name.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
def matches?(name)
|
|
34
39
|
name = name.to_s
|
|
35
40
|
name == @name || @alts.include?(name)
|
data/lib/hammer/loader.rb
CHANGED
|
@@ -38,16 +38,16 @@ class Hammer
|
|
|
38
38
|
|
|
39
39
|
def resolve_pattern(anchor, pattern)
|
|
40
40
|
abs = File.expand_path(pattern, anchor)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
if abs.match?(/[\*\?\[\{]/)
|
|
42
|
+
matches = Dir.glob(abs).sort
|
|
43
|
+
raise Hammer::Error, "load: no files matched #{pattern.inspect}" if matches.empty?
|
|
44
|
+
return matches
|
|
45
|
+
end
|
|
46
|
+
# Directory: discover *_hammer.rb under it. Empty result is OK
|
|
47
|
+
# (an app may legitimately have no fragments).
|
|
48
|
+
return discover(abs) if File.directory?(abs)
|
|
49
|
+
return [abs] if File.file?(abs)
|
|
50
|
+
raise Hammer::Error, "load: no files matched #{pattern.inspect}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def discover(dir)
|
data/lib/hammer/shell.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'io/console'
|
|
2
|
+
|
|
1
3
|
class Hammer
|
|
2
4
|
# ANSI color/output helpers. Mixed into command instances; also callable
|
|
3
5
|
# directly as `Hammer::Shell.say(...)`.
|
|
@@ -19,15 +21,33 @@ class Hammer
|
|
|
19
21
|
@color = value
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
def paint(text, color = nil
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
def paint(text, color = nil)
|
|
25
|
+
if color && !COLORS.key?(color)
|
|
26
|
+
raise Hammer::Error, "unknown color #{color.inspect} (valid: #{COLORS.keys.join(', ')})"
|
|
27
|
+
end
|
|
28
|
+
return text.to_s unless color? && color
|
|
29
|
+
"\e[#{COLORS[color]}m#{text}\e[0m"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `say` with no args returns a proxy so you can write `say.cyan 'hi'`.
|
|
33
|
+
# `say('')` still prints a blank line; `say('x', :cyan)` is unchanged.
|
|
34
|
+
def say(text = nil, color = nil)
|
|
35
|
+
return SayProxy.new if text.nil?
|
|
36
|
+
puts paint(text, color)
|
|
27
37
|
end
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
class SayProxy
|
|
40
|
+
COLORS.each_key do |name|
|
|
41
|
+
define_method(name) { |text = ''| Shell.say(text, name) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def method_missing(name, *)
|
|
45
|
+
raise Hammer::Error, "unknown color :#{name} (valid: #{COLORS.keys.join(', ')})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
49
|
+
false
|
|
50
|
+
end
|
|
31
51
|
end
|
|
32
52
|
|
|
33
53
|
# Raise a controlled Hammer::Error. If unhandled, the dispatcher
|
|
@@ -41,7 +61,7 @@ class Hammer
|
|
|
41
61
|
# Print a red [error] line to stderr (does not exit). Used internally
|
|
42
62
|
# by the dispatcher to render Hammer::Error messages.
|
|
43
63
|
def print_error(text)
|
|
44
|
-
warn paint("[error] #{text}", :red
|
|
64
|
+
warn paint("[error] #{text}", :red)
|
|
45
65
|
end
|
|
46
66
|
|
|
47
67
|
def ask(prompt, default: nil)
|
|
@@ -59,6 +79,75 @@ class Hammer
|
|
|
59
79
|
answer.to_s.strip.downcase.start_with?('y')
|
|
60
80
|
end
|
|
61
81
|
|
|
82
|
+
# Arrow-key picker. Returns the chosen index, or nil on cancel
|
|
83
|
+
# (ESC, Ctrl-C, q). Non-TTY input falls back to a numbered prompt
|
|
84
|
+
# so this stays scriptable.
|
|
85
|
+
#
|
|
86
|
+
# idx = choose 'Pick env', %w[dev staging prod]
|
|
87
|
+
# say.green "chose #{ %w[dev staging prod][idx] }" if idx
|
|
88
|
+
def choose(prompt, items)
|
|
89
|
+
items = items.to_a
|
|
90
|
+
error 'choose needs at least one item' if items.empty?
|
|
91
|
+
|
|
92
|
+
say.cyan prompt
|
|
93
|
+
|
|
94
|
+
return choose_numbered(items) unless $stdin.tty? && $stdin.respond_to?(:raw)
|
|
95
|
+
|
|
96
|
+
selected = 0
|
|
97
|
+
redraw = lambda do |highlight = :cyan|
|
|
98
|
+
items.each_with_index do |item, i|
|
|
99
|
+
puts(i == selected ? paint("> #{item}", highlight) : " #{item}")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
redraw.call
|
|
103
|
+
|
|
104
|
+
$stdout.print "\e[?25l" # hide cursor
|
|
105
|
+
begin
|
|
106
|
+
$stdin.raw do |io|
|
|
107
|
+
loop do
|
|
108
|
+
ch = io.getch
|
|
109
|
+
case ch
|
|
110
|
+
when "\r", "\n"
|
|
111
|
+
# Collapse the list to the chosen line, in green.
|
|
112
|
+
$stdout.print "\e[#{items.size}A\e[J"
|
|
113
|
+
puts paint("> #{items[selected]}", :green)
|
|
114
|
+
return selected
|
|
115
|
+
when "\x03", 'q' # Ctrl-C, q
|
|
116
|
+
$stdout.print "\e[#{items.size}A\e[J"
|
|
117
|
+
return nil
|
|
118
|
+
when "\e"
|
|
119
|
+
# ESC may stand alone or start an arrow sequence \e[A / \e[B.
|
|
120
|
+
if IO.select([io], nil, nil, 0.01) && io.getch == '['
|
|
121
|
+
case io.getch
|
|
122
|
+
when 'A' then selected = (selected - 1) % items.size
|
|
123
|
+
when 'B' then selected = (selected + 1) % items.size
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
$stdout.print "\e[#{items.size}A\e[J"
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
when 'k' then selected = (selected - 1) % items.size
|
|
130
|
+
when 'j' then selected = (selected + 1) % items.size
|
|
131
|
+
end
|
|
132
|
+
$stdout.print "\e[#{items.size}A\e[J"
|
|
133
|
+
redraw.call
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
ensure
|
|
137
|
+
$stdout.print "\e[?25h" # show cursor
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Fallback for non-TTY stdin (pipes, tests). Returns the index or nil.
|
|
142
|
+
def choose_numbered(items)
|
|
143
|
+
items.each_with_index { |item, i| puts " #{i + 1}) #{item}" }
|
|
144
|
+
print paint("select [1-#{items.size}]: ", :cyan)
|
|
145
|
+
line = $stdin.gets
|
|
146
|
+
return nil if line.nil?
|
|
147
|
+
idx = line.strip.to_i - 1
|
|
148
|
+
idx.between?(0, items.size - 1) ? idx : nil
|
|
149
|
+
end
|
|
150
|
+
|
|
62
151
|
# Run a shell command. Echoes the command in gray, raises
|
|
63
152
|
# Hammer::Error on non-zero exit. Returns true on success.
|
|
64
153
|
def sh(cmd)
|
|
@@ -68,3 +157,10 @@ class Hammer
|
|
|
68
157
|
end
|
|
69
158
|
end
|
|
70
159
|
end
|
|
160
|
+
|
|
161
|
+
# `"hi".color(:cyan)` -> ANSI-painted string. Raises on unknown color.
|
|
162
|
+
class String
|
|
163
|
+
def color(name)
|
|
164
|
+
Hammer::Shell.paint(self, name)
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/lux-hammer.rb
CHANGED
|
@@ -50,11 +50,14 @@ class Hammer
|
|
|
50
50
|
super
|
|
51
51
|
sub.instance_variable_set(:@commands, {})
|
|
52
52
|
sub.instance_variable_set(:@namespaces, {})
|
|
53
|
+
sub.instance_variable_set(:@before_hooks, [])
|
|
54
|
+
sub.instance_variable_set(:@parent, nil)
|
|
53
55
|
sub.instance_variable_set(:@program_name, nil)
|
|
54
56
|
sub.instance_variable_set(:@pending_desc, nil)
|
|
55
57
|
sub.instance_variable_set(:@pending_examples, [])
|
|
56
58
|
sub.instance_variable_set(:@pending_options, [])
|
|
57
59
|
sub.instance_variable_set(:@pending_alts, [])
|
|
60
|
+
sub.instance_variable_set(:@pending_needs, [])
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
# ----- class-level DSL for `def`-style commands ---------------------
|
|
@@ -72,6 +75,7 @@ class Hammer
|
|
|
72
75
|
def example(text) ; @pending_examples << text end
|
|
73
76
|
def opt(name, **o) ; @pending_options << Option.new(name, **o) end
|
|
74
77
|
def alt(*names) ; @pending_alts.concat(names) end
|
|
78
|
+
def needs(*names) ; @pending_needs.concat(names) end
|
|
75
79
|
|
|
76
80
|
def method_added(method_name)
|
|
77
81
|
super
|
|
@@ -81,6 +85,7 @@ class Hammer
|
|
|
81
85
|
@pending_examples.each { |e| cmd.add_example(e) }
|
|
82
86
|
@pending_options.each { |o| cmd.add_option(o) }
|
|
83
87
|
@pending_alts.each { |n| cmd.add_alt(n) }
|
|
88
|
+
@pending_needs.each { |n| cmd.add_need(n) }
|
|
84
89
|
|
|
85
90
|
# If the method takes no args, call it without opts. Otherwise pass
|
|
86
91
|
# opts. So both `def build` and `def build(opts)` work.
|
|
@@ -93,6 +98,7 @@ class Hammer
|
|
|
93
98
|
@pending_examples = []
|
|
94
99
|
@pending_options = []
|
|
95
100
|
@pending_alts = []
|
|
101
|
+
@pending_needs = []
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
def program_name(name = nil)
|
|
@@ -149,6 +155,7 @@ class Hammer
|
|
|
149
155
|
@pending_examples = []
|
|
150
156
|
@pending_options = []
|
|
151
157
|
@pending_alts = []
|
|
158
|
+
@pending_needs = []
|
|
152
159
|
end
|
|
153
160
|
|
|
154
161
|
# Open a namespace (group of commands). Everything inside the block
|
|
@@ -165,6 +172,9 @@ class Hammer
|
|
|
165
172
|
# (`hammer_<colon_path>`) from inside a namespaced command dispatches
|
|
166
173
|
# against the full tree, not just the current namespace.
|
|
167
174
|
sub.instance_variable_set(:@root, root)
|
|
175
|
+
# Parent link, so `before` hooks defined further up the namespace
|
|
176
|
+
# tree can be collected and run outer -> inner before a command.
|
|
177
|
+
sub.instance_variable_set(:@parent, self)
|
|
168
178
|
# Inherit program_name so help banners show "myapp ns:cmd", not
|
|
169
179
|
# whichever binary the namespace class fell back to.
|
|
170
180
|
sub.program_name(program_name) if @program_name
|
|
@@ -172,6 +182,38 @@ class Hammer
|
|
|
172
182
|
@namespaces[name.to_s] = sub
|
|
173
183
|
end
|
|
174
184
|
|
|
185
|
+
# Register a hook to run before every command in this class (root or
|
|
186
|
+
# namespace). Hooks receive the command's `opts` hash. All hooks run
|
|
187
|
+
# outer -> inner, once per top-level `start` (prereqs don't re-trigger).
|
|
188
|
+
#
|
|
189
|
+
# before { |opts| Dotenv.load }
|
|
190
|
+
# namespace :db do
|
|
191
|
+
# before { hammer_env }
|
|
192
|
+
# define :migrate do ... end
|
|
193
|
+
# end
|
|
194
|
+
def before(&block)
|
|
195
|
+
before_hooks << block
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def before_hooks
|
|
199
|
+
@before_hooks
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def parent
|
|
203
|
+
@parent
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Root -> ... -> self. Used to gather `before` hooks for a command.
|
|
207
|
+
def ancestor_chain
|
|
208
|
+
chain = []
|
|
209
|
+
klass = self
|
|
210
|
+
while klass
|
|
211
|
+
chain.unshift klass
|
|
212
|
+
klass = klass.parent
|
|
213
|
+
end
|
|
214
|
+
chain
|
|
215
|
+
end
|
|
216
|
+
|
|
175
217
|
# Topmost class in this CLI tree. For user-defined `class MyCli < Hammer`
|
|
176
218
|
# or `Class.new(Hammer)` it's self; for namespace subclasses it's
|
|
177
219
|
# whichever class opened the namespace.
|
|
@@ -216,6 +258,14 @@ class Hammer
|
|
|
216
258
|
# Command names are Rake-style colon paths: "build", "db:migrate",
|
|
217
259
|
# "db:users:list".
|
|
218
260
|
def start(argv = ARGV)
|
|
261
|
+
# Track prereqs fired during this top-level invocation so a `needs`
|
|
262
|
+
# chain runs each prereq at most once. Nested `start` calls (e.g.
|
|
263
|
+
# `needs` -> `hammer_*` -> `start`) share the set; the outermost
|
|
264
|
+
# call owns its lifetime.
|
|
265
|
+
outer = Thread.current[:hammer_needs_ran].nil?
|
|
266
|
+
Thread.current[:hammer_needs_ran] ||= {}
|
|
267
|
+
Thread.current[:hammer_before_ran] ||= {}
|
|
268
|
+
|
|
219
269
|
argv = argv.dup
|
|
220
270
|
name = argv.shift
|
|
221
271
|
|
|
@@ -233,6 +283,9 @@ class Hammer
|
|
|
233
283
|
Shell.print_error("unknown command: #{name}")
|
|
234
284
|
print_help
|
|
235
285
|
exit 1
|
|
286
|
+
ensure
|
|
287
|
+
Thread.current[:hammer_needs_ran] = nil if outer
|
|
288
|
+
Thread.current[:hammer_before_ran] = nil if outer
|
|
236
289
|
end
|
|
237
290
|
|
|
238
291
|
# Find a command by canonical name or alt within this class.
|
|
@@ -309,6 +362,8 @@ class Hammer
|
|
|
309
362
|
positional, opts = Parser.new(cmd.options).parse(argv)
|
|
310
363
|
opts[:args] = positional
|
|
311
364
|
instance = new
|
|
365
|
+
run_before_hooks(instance, opts)
|
|
366
|
+
run_needs(cmd)
|
|
312
367
|
instance.instance_exec(opts, &cmd.handler)
|
|
313
368
|
rescue Parser::Error => e
|
|
314
369
|
Shell.print_error(e.message)
|
|
@@ -321,6 +376,33 @@ class Hammer
|
|
|
321
376
|
exit 1
|
|
322
377
|
end
|
|
323
378
|
|
|
379
|
+
# Fire `before` hooks from root down through the namespace chain.
|
|
380
|
+
# Each class's hooks fire at most once per top-level `start`, so
|
|
381
|
+
# prereqs dispatched via `needs` won't re-trigger them.
|
|
382
|
+
def run_before_hooks(instance, opts)
|
|
383
|
+
ran = Thread.current[:hammer_before_ran] ||= {}
|
|
384
|
+
ancestor_chain.each do |klass|
|
|
385
|
+
next if ran[klass.object_id]
|
|
386
|
+
ran[klass.object_id] = true
|
|
387
|
+
klass.before_hooks.each { |hook| instance.instance_exec(opts, &hook) }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Dispatch a command's declared `needs` through the root class, with
|
|
392
|
+
# per-invocation dedupe. Prereqs run with default options (no argv).
|
|
393
|
+
def run_needs(cmd)
|
|
394
|
+
return if cmd.needs.empty?
|
|
395
|
+
ran = Thread.current[:hammer_needs_ran] ||= {}
|
|
396
|
+
cmd.needs.each do |path|
|
|
397
|
+
key = path.to_s
|
|
398
|
+
next if ran[key]
|
|
399
|
+
ran[key] = true
|
|
400
|
+
target, = root.resolve(key)
|
|
401
|
+
raise Error, "needs: unknown command '#{key}' in #{cmd.name}" unless target
|
|
402
|
+
root.start([key])
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
324
406
|
def help_requested?(argv)
|
|
325
407
|
stop = argv.index('--')
|
|
326
408
|
scan = stop ? argv[0...stop] : argv
|
|
@@ -337,15 +419,15 @@ class Hammer
|
|
|
337
419
|
return
|
|
338
420
|
end
|
|
339
421
|
|
|
340
|
-
Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
|
|
341
|
-
Shell.say
|
|
422
|
+
Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
|
|
423
|
+
Shell.say ''
|
|
342
424
|
print_command_list(self)
|
|
343
425
|
print_footer
|
|
344
426
|
end
|
|
345
427
|
|
|
346
428
|
def print_namespace_help(prefix, ns)
|
|
347
|
-
Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
|
|
348
|
-
Shell.say
|
|
429
|
+
Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
|
|
430
|
+
Shell.say ''
|
|
349
431
|
print_command_list(ns, prefix)
|
|
350
432
|
print_footer
|
|
351
433
|
end
|
|
@@ -353,13 +435,16 @@ class Hammer
|
|
|
353
435
|
HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
|
|
354
436
|
|
|
355
437
|
def print_footer
|
|
356
|
-
Shell.say
|
|
438
|
+
Shell.say ''
|
|
357
439
|
Shell.say "powered by hammer - #{HOMEPAGE}", :gray
|
|
358
440
|
end
|
|
359
441
|
|
|
360
442
|
def print_command_list(klass, prefix = nil)
|
|
361
443
|
rows = []
|
|
362
|
-
|
|
444
|
+
# Commands without a `desc` are hidden from listings but still
|
|
445
|
+
# dispatchable + `hammer_*`-callable - useful for private helpers
|
|
446
|
+
# invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
|
|
447
|
+
klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? }
|
|
363
448
|
return if rows.empty?
|
|
364
449
|
|
|
365
450
|
# group by "section" = everything between the view prefix and the
|
|
@@ -422,19 +507,19 @@ class Hammer
|
|
|
422
507
|
|
|
423
508
|
def print_command_help(cmd, full = nil)
|
|
424
509
|
full ||= cmd.name
|
|
425
|
-
Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan
|
|
510
|
+
Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan
|
|
426
511
|
cmd.desc.each_line do |line|
|
|
427
512
|
stripped = line.chomp
|
|
428
513
|
Shell.say(stripped.empty? ? '' : " #{stripped}")
|
|
429
514
|
end unless cmd.desc.empty?
|
|
430
515
|
Shell.say " alias: #{cmd.alts.join(', ')}" unless cmd.alts.empty?
|
|
431
516
|
unless cmd.options.empty?
|
|
432
|
-
Shell.say
|
|
517
|
+
Shell.say ''
|
|
433
518
|
Shell.say 'Options:', :yellow
|
|
434
519
|
cmd.options.each { |o| Shell.say " #{o.usage}" }
|
|
435
520
|
end
|
|
436
521
|
unless cmd.examples.empty?
|
|
437
|
-
Shell.say
|
|
522
|
+
Shell.say ''
|
|
438
523
|
Shell.say 'Examples:', :yellow
|
|
439
524
|
cmd.examples.each { |e| Shell.say " #{program_name} #{e}" }
|
|
440
525
|
end
|
|
@@ -465,9 +550,21 @@ class Hammer
|
|
|
465
550
|
|
|
466
551
|
# Define and run a CLI inline. Inside the block use `program`,
|
|
467
552
|
# `define :name do ... end`, and `namespace`.
|
|
553
|
+
#
|
|
554
|
+
# Without a block: load ./Hammerfile if it exists, otherwise
|
|
555
|
+
# auto-discover *_hammer.rb under Dir.pwd, then dispatch ARGV.
|
|
468
556
|
def self.run(argv = ARGV, &block)
|
|
469
557
|
klass = Class.new(Hammer)
|
|
470
|
-
|
|
558
|
+
if block
|
|
559
|
+
Builder.new(klass).instance_eval(&block)
|
|
560
|
+
else
|
|
561
|
+
hf = File.join(Dir.pwd, 'Hammerfile')
|
|
562
|
+
if File.file?(hf)
|
|
563
|
+
Builder.new(klass).instance_eval(File.read(hf), hf)
|
|
564
|
+
else
|
|
565
|
+
klass.loader.load(Dir.pwd, [], auto: true)
|
|
566
|
+
end
|
|
567
|
+
end
|
|
471
568
|
klass.start(argv)
|
|
472
569
|
end
|
|
473
570
|
|