lux-hammer 0.3.10 → 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.
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
- arity = instance_method(method_name).arity
98
- cmd.handler = arity.zero? ? proc { send(m) } : proc { |opts| send(m, opts) }
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
 
@@ -164,7 +164,11 @@ class Hammer
164
164
  end
165
165
  cmd.handler = handler
166
166
 
167
- if (prev = commands[cmd.name])
167
+ # Only warn when overriding a task that was also defined inside the
168
+ # main app. Overriding one that came from outside - a framework
169
+ # default, plugin, or gem - is an intentional override, so stay quiet
170
+ # and don't tag it `(redefined)` in help.
171
+ if (prev = commands[cmd.name]) && app_local_location?(prev.location)
168
172
  cmd.prev_location = prev.location
169
173
  warn_redefinition('task', cmd.name, prev.location, cmd.location)
170
174
  end
@@ -190,28 +194,29 @@ class Hammer
190
194
  # namespace :users do ... end
191
195
  # end
192
196
  #
197
+ # Reopening a namespace merges: the same `namespace :db do ... end` can
198
+ # be split across files (Rake-style) and the blocks accumulate onto one
199
+ # subclass. Only a duplicate *task* name inside warns - that's handled
200
+ # by `task`. The namespace subclass is created lazily on first mention.
193
201
  def namespace(name, &block)
194
- sub = Class.new(Hammer)
195
- # Track the top-level CLI class so cross-invocation
196
- # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
197
- # against the full tree, not just the current namespace.
198
- sub.instance_variable_set(:@root, root)
199
- # Parent link, so `before` hooks defined further up the namespace
200
- # tree can be collected and run outer -> inner before a command.
201
- sub.instance_variable_set(:@parent, self)
202
- # Share the parent's resolved program_name so help banners show
203
- # "myapp ns:cmd" with the same prefix everywhere - and so the value
204
- # captured pre-chdir (see `Hammer.cli`) survives into nested classes.
205
- sub.instance_variable_set(:@program_name, program_name)
206
- sub.instance_variable_set(:@location, source_location_of(block))
207
- Hammer.with_target(sub) { sub.class_eval(&block) } if block
208
-
209
- if (prev = @namespaces[name.to_s])
210
- sub.instance_variable_set(:@prev_location, prev.instance_variable_get(:@location))
211
- warn_redefinition('namespace', name.to_s, prev.instance_variable_get(:@location), sub.instance_variable_get(:@location))
212
- end
202
+ sub = (@namespaces[name.to_s] ||= begin
203
+ ns = Class.new(Hammer)
204
+ # Track the top-level CLI class so cross-invocation
205
+ # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
206
+ # against the full tree, not just the current namespace.
207
+ ns.instance_variable_set(:@root, root)
208
+ # Parent link, so `before` hooks defined further up the namespace
209
+ # tree can be collected and run outer -> inner before a command.
210
+ ns.instance_variable_set(:@parent, self)
211
+ # Share the parent's resolved program_name so help banners show
212
+ # "myapp ns:cmd" with the same prefix everywhere - and so the value
213
+ # captured pre-chdir (see `Hammer.cli`) survives into nested classes.
214
+ ns.instance_variable_set(:@program_name, program_name)
215
+ ns.instance_variable_set(:@location, source_location_of(block))
216
+ ns
217
+ end)
213
218
 
214
- @namespaces[name.to_s] = sub
219
+ Hammer.with_target(sub) { sub.class_eval(&block) } if block
215
220
  end
216
221
 
217
222
  # Register a hook to run before every command in this class (root or
@@ -252,7 +257,23 @@ class Hammer
252
257
  # built-in C-defined procs, eval'd blocks).
253
258
  def source_location_of(block)
254
259
  loc = block&.source_location
255
- loc ? "#{loc[0]}:#{loc[1]}" : '(unknown)'
260
+ loc ? "#{relativize_path(loc[0])}:#{loc[1]}" : '(unknown)'
261
+ end
262
+
263
+ # Trim the cwd prefix off an absolute path so redefinition warnings
264
+ # read as `./lib/tasks/foo.rb` instead of a long absolute path. Paths
265
+ # outside cwd (framework / gem files) are left absolute.
266
+ def relativize_path(path)
267
+ prefix = "#{Dir.pwd}/"
268
+ path.start_with?(prefix) ? ".#{path[Dir.pwd.length..]}" : path
269
+ end
270
+
271
+ # True when a captured location lives inside the main app. relativize_path
272
+ # rewrites in-app absolute paths to a `.`-relative form, so anything still
273
+ # starting with "/" is an absolute path outside cwd - a framework, plugin,
274
+ # or gem file. Relative locations are already cwd-anchored, hence local.
275
+ def app_local_location?(loc)
276
+ !loc.to_s.start_with?('/')
256
277
  end
257
278
 
258
279
  # Emit a yellow [hammer] warning on stderr when a task/namespace is
@@ -392,6 +413,13 @@ class Hammer
392
413
  return print_help
393
414
  end
394
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
+
395
423
  cmd, owner, canonical = resolve(name)
396
424
  return owner.run_command(cmd, argv, full: canonical) if cmd
397
425
 
@@ -497,6 +525,7 @@ class Hammer
497
525
  # Tries prefix match first, then substring; raises AmbiguousMatch
498
526
  # when either pass hits more than one item.
499
527
  def fuzzy_pick(name, items, kind, &keys_for)
528
+ return nil if name.empty?
500
529
  [:start_with?, :include?].each do |op|
501
530
  matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
502
531
  next if matches.empty?
@@ -527,21 +556,30 @@ class Hammer
527
556
  opts.each do |k, v|
528
557
  next if v == false
529
558
  flag = "--#{k.to_s.tr('_', '-')}"
530
- argv << (v == true ? flag : "#{flag}=#{v}")
559
+ if v == true
560
+ argv << flag
561
+ else
562
+ argv << "#{flag}=#{v.is_a?(Array) ? v.join(',') : v}"
563
+ end
531
564
  end
532
565
  start(argv)
533
566
  end
534
567
 
535
568
  # Yield [full_colon_path, Command] for every command in this class
536
- # and all nested namespaces.
537
- def each_command(prefix = nil, &block)
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)
538
575
  commands.each_value do |c|
539
576
  full = prefix ? "#{prefix}:#{c.name}" : c.name
540
577
  yield full, c
541
578
  end
542
579
  namespaces.each do |ns_name, sub|
580
+ next if !include_builtins && sub.instance_variable_get(:@builtin_namespace)
543
581
  sub_prefix = prefix ? "#{prefix}:#{ns_name}" : ns_name
544
- sub.each_command(sub_prefix, &block)
582
+ sub.each_command(sub_prefix, include_builtins: include_builtins, &block)
545
583
  end
546
584
  end
547
585
 
@@ -580,7 +618,7 @@ class Hammer
580
618
  if o.boolean?
581
619
  parts << (val ? "--#{o.name}" : "--no-#{o.name}")
582
620
  else
583
- parts << "--#{o.name}=#{val}"
621
+ parts << "--#{o.name}=#{val.is_a?(Array) ? val.join(',') : val}"
584
622
  end
585
623
  end
586
624
  parts.concat(positional)
@@ -591,6 +629,9 @@ class Hammer
591
629
  # Each class's hooks fire at most once per top-level `start`, so
592
630
  # prereqs dispatched via `needs` won't re-trigger them.
593
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)
594
635
  ran = Thread.current[:hammer_before_ran] ||= {}
595
636
  ancestor_chain.each do |klass|
596
637
  next if ran[klass.object_id]
@@ -640,15 +681,29 @@ class Hammer
640
681
 
641
682
  print_top_banner
642
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
643
694
  if @app_desc && !@app_desc.empty?
644
695
  Shell.say ''
645
696
  @app_desc.each_line { |l| Shell.say " #{l.chomp}" }
646
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.
647
702
  if expanded
648
- 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? }
649
704
  else
650
705
  Shell.say ''
651
- print_command_list(self)
706
+ print_command_list(self, include_builtins: extended)
652
707
  end
653
708
  print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
654
709
  print_extras if extended
@@ -667,7 +722,7 @@ class Hammer
667
722
  entries.each do |name, file|
668
723
  desc = Hammer::Recipe.desc(file)
669
724
  installed = Hammer::Recipe.installed_path(name)
670
- suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
725
+ suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} h:recipes --install #{name}]"
671
726
  Shell.say " #{name.ljust(width)} # #{desc}"
672
727
  Shell.say " #{' ' * width} #{suffix}", :gray
673
728
  end
@@ -735,24 +790,24 @@ class Hammer
735
790
 
736
791
  def print_footer
737
792
  Shell.say ''
738
- Shell.say "powered by hammer - #{HOMEPAGE}", :gray
793
+ Shell.say "powered by hammer (v#{VERSION}) - #{HOMEPAGE}", :gray
739
794
  end
740
795
 
741
796
  # Hammerfile cheat-sheet shown under `hammer --help`. Same content
742
797
  # as `hammer --init` writes - single source of truth via
743
- # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.
798
+ # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer h:agents`.
744
799
  def print_hammerfile_example
745
800
  Shell.say ''
746
801
  Shell.say 'Hammerfile example:', :yellow
747
802
  Shell.say Hammer::STARTER_HAMMERFILE
748
803
  end
749
804
 
750
- def print_command_list(klass, prefix = nil)
805
+ def print_command_list(klass, prefix = nil, include_builtins: true)
751
806
  rows = []
752
807
  # Commands without a `desc` are hidden from listings but still
753
808
  # dispatchable + `hammer`-callable - useful for private helpers
754
809
  # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
755
- 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? }
756
811
  return if rows.empty?
757
812
 
758
813
  # group by "section" = everything between the view prefix and the
@@ -890,7 +945,7 @@ class Hammer
890
945
  Shell.print_error "unknown recipe: #{name}"
891
946
  Shell.say 'available recipes:', :yellow
892
947
  Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
893
- Shell.say 'try `hammer recipes` to list with descriptions', :gray
948
+ Shell.say 'try `hammer h:recipes` to list with descriptions', :gray
894
949
  exit 1
895
950
  end
896
951
 
@@ -946,12 +1001,12 @@ class Hammer
946
1001
  end
947
1002
  RUBY
948
1003
 
949
- # Default install dir used by install.sh and `hammer update`.
1004
+ # Default install dir used by install.sh and `hammer h:update`.
950
1005
  SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
951
1006
  SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
952
1007
  SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
953
1008
 
954
- # `hammer update`: pull main in the install-script checkout and
1009
+ # `hammer h:update`: pull main in the install-script checkout and
955
1010
  # reinstall the gem. Assumes the install.sh layout - if the dir is
956
1011
  # missing, point the user at the curl-pipe installer.
957
1012
  def self.self_update
@@ -1007,15 +1062,17 @@ class Hammer
1007
1062
  path = force_system ? nil : find_hammerfile(Dir.pwd)
1008
1063
  unless path
1009
1064
  # No Hammerfile (or --system) - all built-ins are reachable. Bare
1010
- # `hammer`, `hammer recipes`, `hammer update`, `hammer agents`,
1011
- # `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.
1012
1067
  if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
1013
1068
  klass = Class.new(Hammer)
1014
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)
1015
1073
  klass.program_name
1016
1074
  require_relative 'hammer/builtins'
1017
- Hammer::Builtins.register_core(klass)
1018
- Hammer::Builtins.register_no_project(klass)
1075
+ Hammer::Builtins.register(klass)
1019
1076
  klass.start(argv)
1020
1077
  return
1021
1078
  end
@@ -1039,8 +1096,8 @@ class Hammer
1039
1096
  Shell.say STARTER_HAMMERFILE
1040
1097
  Shell.say ''
1041
1098
  bin = File.basename($PROGRAM_NAME)
1042
- Shell.say "tip: run `#{bin} init` to drop the example above into ./Hammerfile", :gray
1043
- 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
1044
1101
  exit 1
1045
1102
  end
1046
1103
 
@@ -1061,14 +1118,13 @@ class Hammer
1061
1118
  # are NOT visible during Hammerfile evaluation, only inside handlers.
1062
1119
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
1063
1120
 
1064
- # Core built-ins register AFTER Hammerfile eval so user-defined
1065
- # tasks win (the `unless commands.key?(...)` guards in register_core
1066
- # skip the built-in when overridden - no redefinition warning).
1067
- # `register_no_project` (:recipes, :init) is intentionally NOT
1068
- # called here - those would clash too easily with user tasks. Use
1069
- # `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.
1070
1126
  require_relative 'hammer/builtins'
1071
- Hammer::Builtins.register_core(klass)
1127
+ Hammer::Builtins.register(klass)
1072
1128
 
1073
1129
  klass.start(argv)
1074
1130
  end
@@ -1088,7 +1144,7 @@ class Hammer
1088
1144
  end
1089
1145
 
1090
1146
  # Evaluate a shebang script as a self-contained CLI. Mirrors `recipe`
1091
- # semantics: no chdir, no `@hammer_binary` flag, no `register_core`
1147
+ # semantics: no chdir, no `@hammer_binary` flag, no `Builtins.register`
1092
1148
  # built-ins (so the script's `--help` shows only what it defines).
1093
1149
  # `program_name` is the script's basename so help reads "myscript foo"
1094
1150
  # rather than "hammer foo" - works even when invoked via a symlink in
@@ -1110,15 +1166,14 @@ class Hammer
1110
1166
  first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
1111
1167
  end
1112
1168
 
1113
- # True if argv names a built-in task (`recipes`, `update`, `agents`,
1114
- # `version`, `init`). Used in the no-Hammerfile branch to wake up the
1115
- # 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
1116
1172
  # or help request.
1117
- BUILTIN_TASKS ||= %w[recipes update agents version init].freeze
1118
1173
  def self.looks_like_builtin?(argv)
1119
1174
  first = argv.first
1120
1175
  return false unless first
1121
- BUILTIN_TASKS.include?(first) || BUILTIN_TASKS.any? { |t| first.start_with?("#{t}:") }
1176
+ first == 'h' || first.start_with?('h:')
1122
1177
  end
1123
1178
 
1124
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'