lux-hammer 0.3.12 → 0.3.13
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 +45 -39
- data/README.md +47 -44
- data/lib/hammer/builtins.rb +40 -33
- data/lib/hammer/dotenv.rb +3 -2
- data/lib/hammer/loader.rb +13 -7
- data/lib/hammer/option.rb +2 -0
- data/lib/hammer/parser.rb +40 -10
- data/lib/hammer/recipe.rb +2 -2
- data/lib/hammer/shell.rb +5 -2
- data/lib/lux-hammer.rb +69 -35
- data/recipes/deploy.rb +32 -0
- data/recipes/lib/deploy/boot.rb +52 -0
- data/recipes/lib/deploy/commands.rb +555 -0
- data/recipes/lib/deploy/config.rb +62 -0
- data/recipes/lib/deploy/context.rb +149 -0
- data/recipes/lib/deploy/doctor.rb +238 -0
- data/recipes/lib/deploy/hammer.rb +168 -0
- data/recipes/lib/deploy/manifest.rb +169 -0
- data/recipes/lib/deploy/ssh.rb +129 -0
- data/recipes/lib/deploy/template.rb +39 -0
- metadata +12 -2
data/lib/lux-hammer.rb
CHANGED
|
@@ -94,8 +94,8 @@ class Hammer
|
|
|
94
94
|
# If the method takes no args, call it without opts. Otherwise pass
|
|
95
95
|
# opts. So both `def build` and `def build(opts)` work.
|
|
96
96
|
m = method_name
|
|
97
|
-
|
|
98
|
-
cmd.handler =
|
|
97
|
+
takes_arg = instance_method(method_name).parameters.any? { |type, _| %i[req opt rest].include?(type) }
|
|
98
|
+
cmd.handler = takes_arg ? proc { |opts| send(m, opts) } : proc { send(m) }
|
|
99
99
|
cmd.finalize!
|
|
100
100
|
commands[cmd.name] = cmd
|
|
101
101
|
|
|
@@ -413,6 +413,13 @@ class Hammer
|
|
|
413
413
|
return print_help
|
|
414
414
|
end
|
|
415
415
|
|
|
416
|
+
# An exact namespace beats a fuzzy command match, so `hammer h` lists
|
|
417
|
+
# the `h:` namespace instead of prefix-matching some `h...` command.
|
|
418
|
+
if !commands.key?(name) && namespaces.key?(name)
|
|
419
|
+
ns, canonical = resolve_namespace(name)
|
|
420
|
+
return print_namespace_help(canonical, ns)
|
|
421
|
+
end
|
|
422
|
+
|
|
416
423
|
cmd, owner, canonical = resolve(name)
|
|
417
424
|
return owner.run_command(cmd, argv, full: canonical) if cmd
|
|
418
425
|
|
|
@@ -518,6 +525,7 @@ class Hammer
|
|
|
518
525
|
# Tries prefix match first, then substring; raises AmbiguousMatch
|
|
519
526
|
# when either pass hits more than one item.
|
|
520
527
|
def fuzzy_pick(name, items, kind, &keys_for)
|
|
528
|
+
return nil if name.empty?
|
|
521
529
|
[:start_with?, :include?].each do |op|
|
|
522
530
|
matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
|
|
523
531
|
next if matches.empty?
|
|
@@ -548,21 +556,30 @@ class Hammer
|
|
|
548
556
|
opts.each do |k, v|
|
|
549
557
|
next if v == false
|
|
550
558
|
flag = "--#{k.to_s.tr('_', '-')}"
|
|
551
|
-
|
|
559
|
+
if v == true
|
|
560
|
+
argv << flag
|
|
561
|
+
else
|
|
562
|
+
argv << "#{flag}=#{v.is_a?(Array) ? v.join(',') : v}"
|
|
563
|
+
end
|
|
552
564
|
end
|
|
553
565
|
start(argv)
|
|
554
566
|
end
|
|
555
567
|
|
|
556
568
|
# Yield [full_colon_path, Command] for every command in this class
|
|
557
|
-
# and all nested namespaces.
|
|
558
|
-
|
|
569
|
+
# and all nested namespaces. `include_builtins: false` prunes
|
|
570
|
+
# namespaces flagged `@builtin_namespace` (the reserved `h:` tree) -
|
|
571
|
+
# used so the compact listing hides built-ins outside `--help`. Only
|
|
572
|
+
# affects descent from a parent; iterating a flagged namespace
|
|
573
|
+
# directly (e.g. `hammer h:`) still lists its own commands.
|
|
574
|
+
def each_command(prefix = nil, include_builtins: true, &block)
|
|
559
575
|
commands.each_value do |c|
|
|
560
576
|
full = prefix ? "#{prefix}:#{c.name}" : c.name
|
|
561
577
|
yield full, c
|
|
562
578
|
end
|
|
563
579
|
namespaces.each do |ns_name, sub|
|
|
580
|
+
next if !include_builtins && sub.instance_variable_get(:@builtin_namespace)
|
|
564
581
|
sub_prefix = prefix ? "#{prefix}:#{ns_name}" : ns_name
|
|
565
|
-
sub.each_command(sub_prefix, &block)
|
|
582
|
+
sub.each_command(sub_prefix, include_builtins: include_builtins, &block)
|
|
566
583
|
end
|
|
567
584
|
end
|
|
568
585
|
|
|
@@ -601,7 +618,7 @@ class Hammer
|
|
|
601
618
|
if o.boolean?
|
|
602
619
|
parts << (val ? "--#{o.name}" : "--no-#{o.name}")
|
|
603
620
|
else
|
|
604
|
-
parts << "--#{o.name}=#{val}"
|
|
621
|
+
parts << "--#{o.name}=#{val.is_a?(Array) ? val.join(',') : val}"
|
|
605
622
|
end
|
|
606
623
|
end
|
|
607
624
|
parts.concat(positional)
|
|
@@ -612,6 +629,9 @@ class Hammer
|
|
|
612
629
|
# Each class's hooks fire at most once per top-level `start`, so
|
|
613
630
|
# prereqs dispatched via `needs` won't re-trigger them.
|
|
614
631
|
def run_before_hooks(instance, opts)
|
|
632
|
+
# Built-in `h:` meta-commands parent to the project root but must not
|
|
633
|
+
# trigger the project's own `before` hooks (dotenv, env checks, ...).
|
|
634
|
+
return if instance_variable_get(:@builtin_namespace)
|
|
615
635
|
ran = Thread.current[:hammer_before_ran] ||= {}
|
|
616
636
|
ancestor_chain.each do |klass|
|
|
617
637
|
next if ran[klass.object_id]
|
|
@@ -661,15 +681,29 @@ class Hammer
|
|
|
661
681
|
|
|
662
682
|
print_top_banner
|
|
663
683
|
Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
|
|
684
|
+
# Compact (bare-invocation) view only - the extended `--help` view
|
|
685
|
+
# already IS the full usage, so don't nag about it there.
|
|
686
|
+
unless extended
|
|
687
|
+
Shell.say "add `--help` to show usage help", :gray
|
|
688
|
+
# No project Hammerfile + no custom tasks loaded: point the user at
|
|
689
|
+
# `h:init`. The flag is set by `Hammer.cli` when the lookup misses.
|
|
690
|
+
if instance_variable_get(:@no_hammerfile)
|
|
691
|
+
Shell.say "no Hammerfile found in #{Dir.pwd} - run `#{program_name} h:init` to create one", :gray
|
|
692
|
+
end
|
|
693
|
+
end
|
|
664
694
|
if @app_desc && !@app_desc.empty?
|
|
665
695
|
Shell.say ''
|
|
666
696
|
@app_desc.each_line { |l| Shell.say " #{l.chomp}" }
|
|
667
697
|
end
|
|
698
|
+
# Built-in `h:` commands only surface in the extended view
|
|
699
|
+
# (`--help` / `-h` / `help`); the bare-invocation listing stays
|
|
700
|
+
# focused on the project's own tasks. They remain dispatchable
|
|
701
|
+
# regardless - this only governs what the listing shows.
|
|
668
702
|
if expanded
|
|
669
|
-
each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
|
|
703
|
+
each_command(include_builtins: extended) { |path, c| print_full_block(path, c) unless c.desc.empty? }
|
|
670
704
|
else
|
|
671
705
|
Shell.say ''
|
|
672
|
-
print_command_list(self)
|
|
706
|
+
print_command_list(self, include_builtins: extended)
|
|
673
707
|
end
|
|
674
708
|
print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
|
|
675
709
|
print_extras if extended
|
|
@@ -688,7 +722,7 @@ class Hammer
|
|
|
688
722
|
entries.each do |name, file|
|
|
689
723
|
desc = Hammer::Recipe.desc(file)
|
|
690
724
|
installed = Hammer::Recipe.installed_path(name)
|
|
691
|
-
suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
|
|
725
|
+
suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} h:recipes --install #{name}]"
|
|
692
726
|
Shell.say " #{name.ljust(width)} # #{desc}"
|
|
693
727
|
Shell.say " #{' ' * width} #{suffix}", :gray
|
|
694
728
|
end
|
|
@@ -761,19 +795,19 @@ class Hammer
|
|
|
761
795
|
|
|
762
796
|
# Hammerfile cheat-sheet shown under `hammer --help`. Same content
|
|
763
797
|
# as `hammer --init` writes - single source of truth via
|
|
764
|
-
# `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.
|
|
798
|
+
# `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer h:agents`.
|
|
765
799
|
def print_hammerfile_example
|
|
766
800
|
Shell.say ''
|
|
767
801
|
Shell.say 'Hammerfile example:', :yellow
|
|
768
802
|
Shell.say Hammer::STARTER_HAMMERFILE
|
|
769
803
|
end
|
|
770
804
|
|
|
771
|
-
def print_command_list(klass, prefix = nil)
|
|
805
|
+
def print_command_list(klass, prefix = nil, include_builtins: true)
|
|
772
806
|
rows = []
|
|
773
807
|
# Commands without a `desc` are hidden from listings but still
|
|
774
808
|
# dispatchable + `hammer`-callable - useful for private helpers
|
|
775
809
|
# invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
|
|
776
|
-
klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? }
|
|
810
|
+
klass.each_command(prefix, include_builtins: include_builtins) { |full, c| rows << [full, c] unless c.desc.empty? }
|
|
777
811
|
return if rows.empty?
|
|
778
812
|
|
|
779
813
|
# group by "section" = everything between the view prefix and the
|
|
@@ -911,7 +945,7 @@ class Hammer
|
|
|
911
945
|
Shell.print_error "unknown recipe: #{name}"
|
|
912
946
|
Shell.say 'available recipes:', :yellow
|
|
913
947
|
Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
|
|
914
|
-
Shell.say 'try `hammer recipes` to list with descriptions', :gray
|
|
948
|
+
Shell.say 'try `hammer h:recipes` to list with descriptions', :gray
|
|
915
949
|
exit 1
|
|
916
950
|
end
|
|
917
951
|
|
|
@@ -967,12 +1001,12 @@ class Hammer
|
|
|
967
1001
|
end
|
|
968
1002
|
RUBY
|
|
969
1003
|
|
|
970
|
-
# Default install dir used by install.sh and `hammer update`.
|
|
1004
|
+
# Default install dir used by install.sh and `hammer h:update`.
|
|
971
1005
|
SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
|
|
972
1006
|
SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
|
|
973
1007
|
SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
|
|
974
1008
|
|
|
975
|
-
# `hammer update`: pull main in the install-script checkout and
|
|
1009
|
+
# `hammer h:update`: pull main in the install-script checkout and
|
|
976
1010
|
# reinstall the gem. Assumes the install.sh layout - if the dir is
|
|
977
1011
|
# missing, point the user at the curl-pipe installer.
|
|
978
1012
|
def self.self_update
|
|
@@ -1028,15 +1062,17 @@ class Hammer
|
|
|
1028
1062
|
path = force_system ? nil : find_hammerfile(Dir.pwd)
|
|
1029
1063
|
unless path
|
|
1030
1064
|
# No Hammerfile (or --system) - all built-ins are reachable. Bare
|
|
1031
|
-
# `hammer`, `hammer recipes`, `hammer update`, `hammer agents`,
|
|
1032
|
-
# `hammer version`, `hammer init` all work.
|
|
1065
|
+
# `hammer`, `hammer h:recipes`, `hammer h:update`, `hammer h:agents`,
|
|
1066
|
+
# `hammer h:version`, `hammer h:init` all work.
|
|
1033
1067
|
if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
|
|
1034
1068
|
klass = Class.new(Hammer)
|
|
1035
1069
|
klass.instance_variable_set(:@hammer_binary, true)
|
|
1070
|
+
# No project Hammerfile was found - only built-ins are loaded. The
|
|
1071
|
+
# bare-invocation help uses this to note that no Hammerfile exists.
|
|
1072
|
+
klass.instance_variable_set(:@no_hammerfile, true)
|
|
1036
1073
|
klass.program_name
|
|
1037
1074
|
require_relative 'hammer/builtins'
|
|
1038
|
-
Hammer::Builtins.
|
|
1039
|
-
Hammer::Builtins.register_no_project(klass)
|
|
1075
|
+
Hammer::Builtins.register(klass)
|
|
1040
1076
|
klass.start(argv)
|
|
1041
1077
|
return
|
|
1042
1078
|
end
|
|
@@ -1060,8 +1096,8 @@ class Hammer
|
|
|
1060
1096
|
Shell.say STARTER_HAMMERFILE
|
|
1061
1097
|
Shell.say ''
|
|
1062
1098
|
bin = File.basename($PROGRAM_NAME)
|
|
1063
|
-
Shell.say "tip: run `#{bin} init` to drop the example above into ./Hammerfile", :gray
|
|
1064
|
-
Shell.say "tip: run `#{bin} agents` for AI-friendly Hammerfile authoring docs", :gray
|
|
1099
|
+
Shell.say "tip: run `#{bin} h:init` to drop the example above into ./Hammerfile", :gray
|
|
1100
|
+
Shell.say "tip: run `#{bin} h:agents` for AI-friendly Hammerfile authoring docs", :gray
|
|
1065
1101
|
exit 1
|
|
1066
1102
|
end
|
|
1067
1103
|
|
|
@@ -1082,14 +1118,13 @@ class Hammer
|
|
|
1082
1118
|
# are NOT visible during Hammerfile evaluation, only inside handlers.
|
|
1083
1119
|
Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
|
|
1084
1120
|
|
|
1085
|
-
#
|
|
1086
|
-
#
|
|
1087
|
-
#
|
|
1088
|
-
# `
|
|
1089
|
-
#
|
|
1090
|
-
# `hammer --system recipes` to reach them from inside a project.
|
|
1121
|
+
# Built-ins register AFTER Hammerfile eval so user-defined tasks win
|
|
1122
|
+
# (the `unless commands.key?(...)` guards skip a built-in when the
|
|
1123
|
+
# Hammerfile already owns the name - no redefinition warning). All
|
|
1124
|
+
# built-ins live under `h:`, so they can't collide with project root
|
|
1125
|
+
# tasks and the full set registers in every context.
|
|
1091
1126
|
require_relative 'hammer/builtins'
|
|
1092
|
-
Hammer::Builtins.
|
|
1127
|
+
Hammer::Builtins.register(klass)
|
|
1093
1128
|
|
|
1094
1129
|
klass.start(argv)
|
|
1095
1130
|
end
|
|
@@ -1109,7 +1144,7 @@ class Hammer
|
|
|
1109
1144
|
end
|
|
1110
1145
|
|
|
1111
1146
|
# Evaluate a shebang script as a self-contained CLI. Mirrors `recipe`
|
|
1112
|
-
# semantics: no chdir, no `@hammer_binary` flag, no `
|
|
1147
|
+
# semantics: no chdir, no `@hammer_binary` flag, no `Builtins.register`
|
|
1113
1148
|
# built-ins (so the script's `--help` shows only what it defines).
|
|
1114
1149
|
# `program_name` is the script's basename so help reads "myscript foo"
|
|
1115
1150
|
# rather than "hammer foo" - works even when invoked via a symlink in
|
|
@@ -1131,15 +1166,14 @@ class Hammer
|
|
|
1131
1166
|
first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
|
|
1132
1167
|
end
|
|
1133
1168
|
|
|
1134
|
-
# True if argv
|
|
1135
|
-
# `
|
|
1136
|
-
# built-ins for invocations like `hammer recipes` that aren't a flag
|
|
1169
|
+
# True if argv targets the reserved `h:` built-in namespace (`h`, `h:`,
|
|
1170
|
+
# `h:update`, ...). Used in the no-Hammerfile branch to wake up the
|
|
1171
|
+
# built-ins for invocations like `hammer h:recipes` that aren't a flag
|
|
1137
1172
|
# or help request.
|
|
1138
|
-
BUILTIN_TASKS ||= %w[recipes update agents version init].freeze
|
|
1139
1173
|
def self.looks_like_builtin?(argv)
|
|
1140
1174
|
first = argv.first
|
|
1141
1175
|
return false unless first
|
|
1142
|
-
|
|
1176
|
+
first == 'h' || first.start_with?('h:')
|
|
1143
1177
|
end
|
|
1144
1178
|
|
|
1145
1179
|
# Walk up the directory tree looking for a Hammerfile.
|
data/recipes/deploy.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env hammer
|
|
2
|
+
# desc: SSH/rsync deploy - Caddy + systemd + atomic releases
|
|
3
|
+
|
|
4
|
+
desc <<~TXT
|
|
5
|
+
Stupid-simple SSH/rsync deploy (Caddy + systemd + atomic releases).
|
|
6
|
+
|
|
7
|
+
Quickstart:
|
|
8
|
+
deploy app:init # copy templates into ./config/deploy/
|
|
9
|
+
deploy doctor # check & prep the host
|
|
10
|
+
deploy up # deploy current branch
|
|
11
|
+
|
|
12
|
+
Server:
|
|
13
|
+
deploy server:log # tail the systemd journal
|
|
14
|
+
deploy server:ssh # shell into the current release
|
|
15
|
+
deploy server:restart # restart the web service
|
|
16
|
+
deploy log --log errors # dump a remote log
|
|
17
|
+
TXT
|
|
18
|
+
|
|
19
|
+
# Loads the bundled lib (config/ssh/template/doctor/context/manifest/
|
|
20
|
+
# commands/hammer) - same require chain the gem's lib/lux_deploy.rb had.
|
|
21
|
+
require_relative 'lib/deploy/boot'
|
|
22
|
+
|
|
23
|
+
# Auto-load the app's deploy bootstrap, if present, before tasks fire.
|
|
24
|
+
# A consumer can inject Ruby (e.g. a pre-deploy hook) without writing a
|
|
25
|
+
# custom Hammerfile.
|
|
26
|
+
init = File.join(Dir.pwd, 'config', 'deploy', 'init.rb')
|
|
27
|
+
load init if File.file?(init)
|
|
28
|
+
|
|
29
|
+
# `self` here is the recipe's Builder context - same surface a Hammerfile
|
|
30
|
+
# gets. Pass templates_dir explicitly since there's no gem ROOT to fall
|
|
31
|
+
# back to; it resolves to recipes/lib/deploy/templates.
|
|
32
|
+
LuxDeploy::Hammer.register(self, templates_dir: File.join(__dir__, 'lib/deploy/templates'))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'pathname'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'set'
|
|
5
|
+
require 'shellwords'
|
|
6
|
+
require 'yaml'
|
|
7
|
+
|
|
8
|
+
module LuxDeploy
|
|
9
|
+
# Recipe layout: this file and its siblings live under
|
|
10
|
+
# recipes/lib/deploy/, so ROOT points here and `templates/` sits
|
|
11
|
+
# right next to us (no gem root anymore).
|
|
12
|
+
ROOT ||= Pathname.new(__dir__)
|
|
13
|
+
VERSION ||= '0.2.0'
|
|
14
|
+
|
|
15
|
+
# Branches that select `.env` instead of `.env.staging`.
|
|
16
|
+
MAIN_BRANCHES ||= %w[master main]
|
|
17
|
+
|
|
18
|
+
# Server-side conventions. Not config-tunable because doctor and the
|
|
19
|
+
# deploy flow both hardcode these paths in the host setup. A different
|
|
20
|
+
# caddy/systemd layout means a different recipe.
|
|
21
|
+
PORT_RANGE ||= (3010..3990).step(10).to_a
|
|
22
|
+
CADDY_SITES ||= '/etc/caddy/sites'
|
|
23
|
+
SYSTEMD_DIR ||= '/etc/systemd/system'
|
|
24
|
+
|
|
25
|
+
class Error < StandardError
|
|
26
|
+
def to_s
|
|
27
|
+
"ERROR: #{super}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Host-supplied defaults that sit under the user's .yaml. Set once by a
|
|
32
|
+
# wrapping plugin/Hammerfile (e.g. lux-fw seeds 'lux-web' / 'lux-apps'),
|
|
33
|
+
# consumed by Config.new. Empty by default so the recipe stays "generic".
|
|
34
|
+
@defaults = {}
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
attr_reader :defaults
|
|
38
|
+
|
|
39
|
+
def set_defaults(hash)
|
|
40
|
+
@defaults = (hash || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
require_relative 'config'
|
|
46
|
+
require_relative 'ssh'
|
|
47
|
+
require_relative 'template'
|
|
48
|
+
require_relative 'doctor'
|
|
49
|
+
require_relative 'context'
|
|
50
|
+
require_relative 'manifest'
|
|
51
|
+
require_relative 'commands'
|
|
52
|
+
require_relative 'hammer'
|