lux-hammer 0.3.13 → 0.3.15
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 +40 -2
- data/README.md +112 -0
- data/bin/hammer +10 -0
- data/gui/Hammer.app/Contents/Info.plist +16 -0
- data/gui/Hammer.app/Contents/MacOS/HammerGUI +0 -0
- data/lib/hammer/builtins.rb +68 -0
- data/lib/hammer/command.rb +35 -1
- data/lib/hammer/command_builder.rb +8 -0
- data/lib/hammer/cron.rb +151 -0
- data/lib/hammer/cron_server.rb +416 -0
- data/lib/hammer/cron_web.rb +304 -0
- data/lib/hammer/option.rb +19 -0
- data/lib/lux-hammer.rb +90 -32
- data/recipes/git-helper.rb +2 -2
- data/recipes/llm.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a61994d8f82b575a019cfb1171935b334b840cdb5561fcc56e9ba2c141a314d2
|
|
4
|
+
data.tar.gz: a4d4d730c830b4d30de13dc9e0c27e9cc2fa3902c9297154d4fe81ba5365fe94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e4c0a5f1fbf47550df2929670e647cf7e1e390e7b3aab0b71932dc5f9557295953c39cdf9fafc6ac719d53b94ed14058bd1a2bb32bcaf9bb79ff3b2198fee16
|
|
7
|
+
data.tar.gz: b0012fd33259c5ea63af2d3ea0040c4a618292042f842dbda523f462d90fbc9138c04217c3c86a7d52fd56a6b650249ab2dd9bb6f070e1f1a24497ab68e6dbe2
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.15
|
data/AGENTS.md
CHANGED
|
@@ -41,6 +41,9 @@ lib/hammer/shell.rb # ANSI/IO helpers
|
|
|
41
41
|
lib/hammer/option.rb # One option definition
|
|
42
42
|
lib/hammer/parser.rb # ARGV -> [positional, opts_hash]
|
|
43
43
|
lib/hammer/command.rb # One registered command (name, opts, alts, handler)
|
|
44
|
+
lib/hammer/cron.rb # `cron '<expr>'` schedule parser (cron/interval/@shortcut)
|
|
45
|
+
lib/hammer/cron_server.rb # `h:cron` job server (scheduler, logs, state; lazy-loaded)
|
|
46
|
+
lib/hammer/cron_web.rb # job server web UI (stdlib TCPServer; lazy-loaded)
|
|
44
47
|
lib/hammer/loader.rb # `*_hammer.rb` fragment loader (auto/glob/file)
|
|
45
48
|
lib/hammer/builder.rb # Block-DSL context (Hammerfile / Hammer.run)
|
|
46
49
|
lib/hammer/command_builder.rb # `task :name do ... end` context
|
|
@@ -54,6 +57,8 @@ test/option_test.rb # Option declaration / casting
|
|
|
54
57
|
test/command_test.rb # Command data type
|
|
55
58
|
test/shell_test.rb # ANSI / IO helpers
|
|
56
59
|
test/cli_test.rb # `hammer` binary end-to-end
|
|
60
|
+
test/cron_test.rb # schedule parser (matches?/due?/next_run)
|
|
61
|
+
test/cron_server_test.rb # job discovery, state, rotation, web UI
|
|
57
62
|
examples/Hammerfile # Reference Hammerfile
|
|
58
63
|
examples/class_dsl.rb # Reference class DSL usage
|
|
59
64
|
examples/block_dsl.rb # Reference block DSL usage
|
|
@@ -73,11 +78,18 @@ Inside a `task :name do ... end` block (CommandBuilder context):
|
|
|
73
78
|
resolved against root (same lookup as `hammer`), deduped per
|
|
74
79
|
top-level `start` so each prereq fires at most once. Dedupe also
|
|
75
80
|
spans `+`-chained segments. Unknown prereq raises `Hammer::Error`.
|
|
81
|
+
* `cron '<expr>'` - schedule for the `h:cron` job server. Accepts
|
|
82
|
+
5-field cron (`'*/10 * * * *'`), an interval (`'10s'`, `'10m'`,
|
|
83
|
+
`'2h'`, `'1d'`, counted from the last run) or `@hourly` / `@daily` /
|
|
84
|
+
`@weekly` / `@monthly`. Parsed eagerly (`Hammer::Cron.new`) so an invalid
|
|
85
|
+
expression raises `Hammer::Error` at definition time. Stored on the
|
|
86
|
+
Command (`cmd.cron` raw string, `cmd.cron_schedule` parsed), exported
|
|
87
|
+
by `h:json`, shown in per-command help.
|
|
76
88
|
* `proc do |opts| ... end` - **the last expression**, becomes handler
|
|
77
89
|
|
|
78
90
|
At class scope (for `def`-style commands):
|
|
79
91
|
|
|
80
|
-
* `desc`, `example`, `opt`, `alt`, `needs` set pending state
|
|
92
|
+
* `desc`, `example`, `opt`, `alt`, `needs`, `cron` set pending state
|
|
81
93
|
* The next `def` consumes the pending state IF `desc` was set; otherwise
|
|
82
94
|
the method is treated as a plain helper
|
|
83
95
|
* Methods with arity 0 are called without opts; methods that take an arg
|
|
@@ -249,7 +261,7 @@ reserved `h:` namespace.
|
|
|
249
261
|
|
|
250
262
|
* Root: `:default` (hidden, carries `--version` / `-v`).
|
|
251
263
|
* `h:` namespace: `h:help`, `h:update`, `h:agents`, `h:version`,
|
|
252
|
-
`h:recipes`, `h:init`.
|
|
264
|
+
`h:recipes`, `h:init`, `h:json`, `h:cron`.
|
|
253
265
|
|
|
254
266
|
`h:` is the reserved built-in namespace - keeping the tool-meta commands
|
|
255
267
|
there means they never collide with a project's root tasks, so there's
|
|
@@ -279,6 +291,32 @@ Task contracts:
|
|
|
279
291
|
forward flags through). Bare invocation lists.
|
|
280
292
|
* `h:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
|
|
281
293
|
refuses if one exists.
|
|
294
|
+
* `h:json` - `puts JSON` of `root.export_spec` (tasks grouped exactly
|
|
295
|
+
like the bare listing via `section_for`, root group keyed `__root`).
|
|
296
|
+
`--all` keeps the `h:` tree, `--compact` minifies.
|
|
297
|
+
* `h:cron` - foreground job server for tasks with a `cron` schedule.
|
|
298
|
+
Lazy-requires `hammer/cron_server`. Ticks each minute (each second
|
|
299
|
+
when any job has a sub-minute interval); every due job
|
|
300
|
+
runs in a fresh subprocess (`hammer ns:task`, env `HAMMER_QUIET=1
|
|
301
|
+
NO_COLOR=1`, stdin closed, chdir to the Hammerfile dir), stdout+stderr
|
|
302
|
+
appended to `log/hammer/<slug>.log` (`:` -> `-`; rotated to `.log.1`
|
|
303
|
+
past 1 MB, max 2 files). Last runs persist in
|
|
304
|
+
`tmp/hammer/cron.state.json` (atomic tmp+rename) so restarts don't
|
|
305
|
+
re-fire; a live pid recorded there blocks a second instance.
|
|
306
|
+
Running state persists too: each active run's child pid is saved, and
|
|
307
|
+
on restart a still-alive orphan is restored as `running` (a watcher
|
|
308
|
+
polls until it exits; the overlap guard keeps skipping), while a dead
|
|
309
|
+
one becomes status `unknown` (exit status lost) instead of a stale
|
|
310
|
+
`running`. Overlapping runs of the same job are skipped, never
|
|
311
|
+
queued. Web UI on
|
|
312
|
+
`127.0.0.1` only (default port 4267, hand-rolled HTTP in
|
|
313
|
+
`hammer/cron_web.rb` - no webrick, keep it dependency-free): jobs
|
|
314
|
+
table, per-job log tail, POST run-now, `/json` status. Flags:
|
|
315
|
+
`--port`, `--pass` (HTTP basic auth on every route, any username,
|
|
316
|
+
constant-time compare; falls back to `HAMMER_CRON_PASS` env),
|
|
317
|
+
`--list` (print jobs + next runs, exit), `--service` (print launchd
|
|
318
|
+
plist / systemd unit, carries `--pass` when set, install hints on
|
|
319
|
+
stderr).
|
|
282
320
|
|
|
283
321
|
The `:default` task and the `help` / `-h` / `--help` requests are
|
|
284
322
|
invoked via `run_command(cmd, argv, full: name, quiet: true)` - the
|
data/README.md
CHANGED
|
@@ -562,6 +562,13 @@ Under the `h:` namespace:
|
|
|
562
562
|
* `h:version` - print the lux-hammer version.
|
|
563
563
|
* `h:recipes` - list / install / show / edit recipes.
|
|
564
564
|
* `h:init` - write a starter Hammerfile in cwd (refuses if one exists).
|
|
565
|
+
* `h:json` - dump the CLI definition as JSON (tasks grouped like the
|
|
566
|
+
bare listing, with desc/options/examples/aliases/needs/cron); `--all`
|
|
567
|
+
includes the `h:` tasks, `--compact` minifies. Output is plain stdout
|
|
568
|
+
(the run banner goes to stderr), so `hammer h:json | jq` just works.
|
|
569
|
+
* `h:cron` - job server: runs every task that declares a
|
|
570
|
+
`cron '<expr>'` schedule, with a localhost web UI for status and
|
|
571
|
+
logs. See [Cron / job server](#cron--job-server).
|
|
565
572
|
|
|
566
573
|
Each is guarded by `unless commands.key?` within the namespace, so you
|
|
567
574
|
can override one by reopening `namespace :h` in your Hammerfile.
|
|
@@ -574,6 +581,16 @@ hammer --system h:recipes # list recipes, ignoring any local Hammer
|
|
|
574
581
|
hammer --system h:recipes --install srt ~/bin/srt
|
|
575
582
|
```
|
|
576
583
|
|
|
584
|
+
`--gui` opens a native macOS window for the current project: a sidebar of
|
|
585
|
+
your tasks (grouped like the listing), a form per task built from its
|
|
586
|
+
options, and a Run button that streams the task's output. It reads the
|
|
587
|
+
project via `h:json` and runs each task as a `hammer <path> ...`
|
|
588
|
+
subprocess. macOS / arm64 only.
|
|
589
|
+
|
|
590
|
+
```sh
|
|
591
|
+
hammer --gui # open the runner for the nearest Hammerfile
|
|
592
|
+
```
|
|
593
|
+
|
|
577
594
|
Customize bare `hammer` by replacing `:default`:
|
|
578
595
|
|
|
579
596
|
```ruby
|
|
@@ -611,6 +628,101 @@ end
|
|
|
611
628
|
`-h` / `--help` stay reserved on every command - you can't shadow them
|
|
612
629
|
with an `opt`.
|
|
613
630
|
|
|
631
|
+
## Cron / job server
|
|
632
|
+
|
|
633
|
+
Any task can declare a schedule with `cron`; `hammer h:cron` then runs
|
|
634
|
+
those tasks on time, like a project-local crontab with a web UI:
|
|
635
|
+
|
|
636
|
+
```ruby
|
|
637
|
+
task :backup do
|
|
638
|
+
desc 'Snapshot the database'
|
|
639
|
+
cron '@daily' # 00:00 every day
|
|
640
|
+
proc { |_| sh 'bin/backup' }
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
namespace :cache do
|
|
644
|
+
task :warm do
|
|
645
|
+
desc 'Refresh hot caches'
|
|
646
|
+
cron '10m' # every 10 minutes
|
|
647
|
+
proc { |_| sh 'bin/warm-cache' }
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
Accepted schedule forms:
|
|
653
|
+
|
|
654
|
+
| form | example | meaning |
|
|
655
|
+
|---|---|---|
|
|
656
|
+
| 5-field cron | `*/10 * * * *` | standard crontab (`min hour dom mon dow`) |
|
|
657
|
+
| interval | `10s`, `10m`, `2h`, `1d` | every N seconds/minutes/hours/days, counted from the last run |
|
|
658
|
+
| shortcut | `@hourly`, `@daily`, `@weekly`, `@monthly` | expands to the cron equivalent |
|
|
659
|
+
|
|
660
|
+
Careful with raw cron fields: `10 * * * *` means "minute 10 of every
|
|
661
|
+
hour", not "every 10 minutes" - that's `*/10 * * * *`, or just `10m`.
|
|
662
|
+
Cron fields support `*`, `a`, `a-b`, `*/n`, `a-b/n` and comma lists
|
|
663
|
+
(numeric values only). Weekday `7` equals `0` (Sunday). Intervals are
|
|
664
|
+
not clock-aligned, so odd periods like `90m` work; a job that never ran
|
|
665
|
+
fires on the server's first tick. The scheduler ticks once per minute,
|
|
666
|
+
or once per second when any job declares a sub-minute interval.
|
|
667
|
+
Invalid expressions fail at Hammerfile load, not at runtime.
|
|
668
|
+
|
|
669
|
+
Run the server:
|
|
670
|
+
|
|
671
|
+
```sh
|
|
672
|
+
hammer h:cron # scheduler + web UI, Ctrl-C to stop
|
|
673
|
+
hammer h:cron --port=7777 # UI on another port (default 4267)
|
|
674
|
+
hammer h:cron --list # print jobs + next runs, then exit
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
The web UI at `http://127.0.0.1:4267` (localhost only, never a public
|
|
678
|
+
interface) lists every job with its schedule, last/next run, status and
|
|
679
|
+
duration, shows per-job logs, and has a "run now" button for manual
|
|
680
|
+
triggers. `GET /json` serves the same status for scripting.
|
|
681
|
+
|
|
682
|
+
On a shared machine, protect the UI with HTTP basic auth - useful since
|
|
683
|
+
"run now" executes tasks:
|
|
684
|
+
|
|
685
|
+
```sh
|
|
686
|
+
hammer h:cron --pass=secret # or: HAMMER_CRON_PASS=secret hammer h:cron
|
|
687
|
+
curl -u :secret http://127.0.0.1:4267/json # any username, password checked
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Prefer the `HAMMER_CRON_PASS` env var when other users can read your
|
|
691
|
+
process list (`ps` shows flag values). A `--service` unit generated
|
|
692
|
+
while `--pass` is set carries the flag along, so keep that file private.
|
|
693
|
+
Note this is plain HTTP - credentials are encoded, not encrypted; for
|
|
694
|
+
anything beyond localhost put a TLS proxy in front.
|
|
695
|
+
|
|
696
|
+
Each run executes in a fresh subprocess (`hammer ns:task` - same
|
|
697
|
+
isolation as the GUI runner), through the full normal pipeline: Bundler,
|
|
698
|
+
dotenv, `before` hooks, `needs`. Output goes to a per-job log:
|
|
699
|
+
|
|
700
|
+
```
|
|
701
|
+
log/hammer/backup.log # current log (db:backup -> db-backup.log)
|
|
702
|
+
log/hammer/backup.log.1 # previous, rotated past 1 MB - max 2 files
|
|
703
|
+
tmp/hammer/cron.state.json # last runs + server pid, survives restarts
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
You probably want `log/hammer/` and `tmp/hammer/` in `.gitignore`.
|
|
707
|
+
|
|
708
|
+
Overlap policy: if a run is still going when the next one comes due,
|
|
709
|
+
the new run is skipped (noted in the job log) - jobs never pile up.
|
|
710
|
+
Because last runs persist in the state file, a restart neither re-fires
|
|
711
|
+
jobs nor resets interval clocks, and a second `h:cron` for the same
|
|
712
|
+
project refuses to start while one is already running. Running state
|
|
713
|
+
survives restarts too: a job whose subprocess outlived a server crash
|
|
714
|
+
is picked up again as `running` (no double-start), and one that died
|
|
715
|
+
with the server shows `unknown` rather than a stale `running`.
|
|
716
|
+
|
|
717
|
+
To keep it running supervised, `--service` prints a ready-to-save
|
|
718
|
+
launchd plist (macOS) or systemd user unit (linux) - it only prints,
|
|
719
|
+
installing is up to you:
|
|
720
|
+
|
|
721
|
+
```sh
|
|
722
|
+
hammer h:cron --service > ~/.config/systemd/user/hammer-cron.service
|
|
723
|
+
systemctl --user enable --now hammer-cron
|
|
724
|
+
```
|
|
725
|
+
|
|
614
726
|
## Prereqs (`needs`)
|
|
615
727
|
|
|
616
728
|
Declare commands that must run before this one (Rake-style task deps):
|
data/bin/hammer
CHANGED
|
@@ -23,5 +23,15 @@ if File.exist?('Gemfile') && !defined?(Bundler)
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
require 'lux-hammer'
|
|
26
|
+
|
|
27
|
+
# `hammer --dir` prints the gem root of the lux-hammer that actually loaded
|
|
28
|
+
# (repo checkout or installed gem). Lets recipes locate the lib without
|
|
29
|
+
# hardcoding a path: require File.join(`hammer --dir`.chomp, 'lib/lux-hammer')
|
|
30
|
+
if ARGV.first == '--dir'
|
|
31
|
+
lib = $LOADED_FEATURES.grep(%r{/lux-hammer\.rb$}).last
|
|
32
|
+
puts File.expand_path('../..', lib) # lib/lux-hammer.rb -> gem root
|
|
33
|
+
exit
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
Hammer.cli(ARGV)
|
|
27
37
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleName</key><string>Hammer</string>
|
|
6
|
+
<key>CFBundleDisplayName</key><string>Hammer</string>
|
|
7
|
+
<key>CFBundleIdentifier</key><string>com.lux-hammer.gui</string>
|
|
8
|
+
<key>CFBundleVersion</key><string>1</string>
|
|
9
|
+
<key>CFBundleShortVersionString</key><string>1.0</string>
|
|
10
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
11
|
+
<key>CFBundleExecutable</key><string>HammerGUI</string>
|
|
12
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
13
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
14
|
+
<key>NSPrincipalClass</key><string>NSApplication</string>
|
|
15
|
+
</dict>
|
|
16
|
+
</plist>
|
|
Binary file
|
data/lib/hammer/builtins.rb
CHANGED
|
@@ -31,6 +31,8 @@ class Hammer
|
|
|
31
31
|
register_version(h) unless h.commands.key?('version')
|
|
32
32
|
register_recipes(h) unless h.commands.key?('recipes')
|
|
33
33
|
register_init(h) unless h.commands.key?('init')
|
|
34
|
+
register_json(h) unless h.commands.key?('json')
|
|
35
|
+
register_cron(h) unless h.commands.key?('cron')
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def register_help(klass)
|
|
@@ -112,6 +114,72 @@ class Hammer
|
|
|
112
114
|
end
|
|
113
115
|
end
|
|
114
116
|
|
|
117
|
+
# The job server: runs every task that declares `cron '<expr>'` on
|
|
118
|
+
# its schedule, in the foreground, with a localhost web UI for
|
|
119
|
+
# status, logs and manual triggers. Per-job logs land in log/hammer/
|
|
120
|
+
# (rotated at 1 MB, max 2 files), state in tmp/hammer/cron.state.json.
|
|
121
|
+
def register_cron(klass)
|
|
122
|
+
klass.class_eval do
|
|
123
|
+
task :cron do
|
|
124
|
+
desc <<~TXT
|
|
125
|
+
Job server: run tasks that declare a `cron '<expr>'` schedule.
|
|
126
|
+
|
|
127
|
+
Runs in the foreground (Ctrl-C to stop) with a web UI on
|
|
128
|
+
localhost for job status, per-job logs and manual runs. Use
|
|
129
|
+
--service to print a launchd plist / systemd unit and let the
|
|
130
|
+
OS supervise it.
|
|
131
|
+
TXT
|
|
132
|
+
example 'h:cron'
|
|
133
|
+
example 'h:cron --port=7777'
|
|
134
|
+
example 'h:cron --pass=secret'
|
|
135
|
+
example 'h:cron --list'
|
|
136
|
+
example 'h:cron --service > ~/.config/systemd/user/hammer-cron.service'
|
|
137
|
+
opt :port, type: :integer, default: 4267, desc: 'web UI port (binds 127.0.0.1 only)'
|
|
138
|
+
opt :pass, type: :string, desc: 'HTTP basic-auth password for the web UI (or HAMMER_CRON_PASS env)'
|
|
139
|
+
opt :list, type: :boolean, desc: 'print scheduled jobs + next runs, then exit'
|
|
140
|
+
opt :service, type: :boolean, desc: 'print a launchd plist (macOS) / systemd unit (linux)'
|
|
141
|
+
proc do |opts|
|
|
142
|
+
if self.class.root.instance_variable_get(:@no_hammerfile)
|
|
143
|
+
error "no Hammerfile found - h:cron needs a project with scheduled tasks"
|
|
144
|
+
end
|
|
145
|
+
require_relative 'cron_server'
|
|
146
|
+
server = Hammer::CronServer.new(self.class.root, port: opts[:port],
|
|
147
|
+
pass: opts[:pass] || ENV['HAMMER_CRON_PASS'])
|
|
148
|
+
if opts[:service]
|
|
149
|
+
server.print_service_unit
|
|
150
|
+
elsif opts[:list]
|
|
151
|
+
server.print_startup_summary
|
|
152
|
+
else
|
|
153
|
+
server.run!
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def register_json(klass)
|
|
161
|
+
klass.class_eval do
|
|
162
|
+
task :json do
|
|
163
|
+
desc <<~TXT
|
|
164
|
+
Dump the CLI definition as JSON: tasks grouped exactly like
|
|
165
|
+
the bare-`hammer` listing, each with desc, options, examples,
|
|
166
|
+
aliases, needs. Consumed by the macOS GUI and any tooling
|
|
167
|
+
that wants the full Hammerfile spec.
|
|
168
|
+
TXT
|
|
169
|
+
example 'h:json'
|
|
170
|
+
example 'h:json --all # include the reserved h: tasks'
|
|
171
|
+
example 'h:json --compact # minified, single line'
|
|
172
|
+
opt :all, type: :boolean, desc: 'include reserved built-in h: tasks'
|
|
173
|
+
opt :compact, type: :boolean, desc: 'minified JSON (default: pretty)'
|
|
174
|
+
proc do |opts|
|
|
175
|
+
require 'json'
|
|
176
|
+
spec = self.class.root.export_spec(include_builtins: opts[:all])
|
|
177
|
+
puts opts[:compact] ? JSON.generate(spec) : JSON.pretty_generate(spec)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
115
183
|
# `:recipes` rolls all recipe-management actions into one task. Bare
|
|
116
184
|
# invocation lists; opts pick the action and positional args carry
|
|
117
185
|
# the recipe name (and optional target path for --install). Run via
|
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, :needs
|
|
4
|
+
attr_reader :name, :desc, :options, :examples, :alts, :needs, :cron
|
|
5
5
|
attr_accessor :handler, :location, :prev_location
|
|
6
6
|
|
|
7
7
|
def initialize(name:, desc: '', handler: nil)
|
|
@@ -35,6 +35,19 @@ class Hammer
|
|
|
35
35
|
@needs << name.to_s
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Store a `cron '<expr>'` schedule. The raw string is kept for
|
|
39
|
+
# display/export; parsing happens here so an invalid expression
|
|
40
|
+
# raises Hammer::Error at definition time, not when the job server
|
|
41
|
+
# starts. The parsed Hammer::Cron is what the scheduler consumes.
|
|
42
|
+
def set_cron(expr)
|
|
43
|
+
@cron_schedule = Hammer::Cron.new(expr)
|
|
44
|
+
@cron = @cron_schedule.source
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cron_schedule
|
|
48
|
+
@cron_schedule
|
|
49
|
+
end
|
|
50
|
+
|
|
38
51
|
def matches?(name)
|
|
39
52
|
name = name.to_s
|
|
40
53
|
name == @name || @alts.include?(name)
|
|
@@ -63,6 +76,27 @@ class Hammer
|
|
|
63
76
|
end
|
|
64
77
|
end
|
|
65
78
|
|
|
79
|
+
# Structured form for JSON export (`h:json`). `path` is the full
|
|
80
|
+
# colon path supplied by the tree walk - a Command doesn't know its
|
|
81
|
+
# own namespace prefix. `hidden` follows the help rule: a task with
|
|
82
|
+
# no `desc` still dispatches but is hidden from listings.
|
|
83
|
+
def to_h(path = name)
|
|
84
|
+
{
|
|
85
|
+
name: name,
|
|
86
|
+
path: path,
|
|
87
|
+
desc: desc,
|
|
88
|
+
brief: brief,
|
|
89
|
+
hidden: desc.empty?,
|
|
90
|
+
redefined: !prev_location.nil?,
|
|
91
|
+
location: location,
|
|
92
|
+
alts: alts,
|
|
93
|
+
needs: needs,
|
|
94
|
+
cron: cron,
|
|
95
|
+
examples: examples,
|
|
96
|
+
options: options.map(&:to_h)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
66
100
|
private
|
|
67
101
|
|
|
68
102
|
def short_flag?(switch)
|
|
@@ -26,5 +26,13 @@ class Hammer
|
|
|
26
26
|
def needs(*names)
|
|
27
27
|
names.each { |n| @cmd.add_need(n) }
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# Schedule this task for the `hammer h:cron` job server. Accepts a
|
|
31
|
+
# 5-field cron expression ('*/10 * * * *'), a simple interval ('10s',
|
|
32
|
+
# '10m', '2h', '1d') or a shortcut (@hourly, @daily, @weekly,
|
|
33
|
+
# @monthly). Validated immediately, so a typo fails at Hammerfile load.
|
|
34
|
+
def cron(expr)
|
|
35
|
+
@cmd.set_cron(expr)
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
end
|
data/lib/hammer/cron.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
class Hammer
|
|
2
|
+
# Schedule of a task declared with `cron '<expr>'`. Parses the
|
|
3
|
+
# expression once (at Hammerfile eval, so typos fail at load time) and
|
|
4
|
+
# answers the scheduler's questions: does this wall-clock minute match,
|
|
5
|
+
# is a run due given the last one, and when is the next run.
|
|
6
|
+
#
|
|
7
|
+
# Accepted forms:
|
|
8
|
+
#
|
|
9
|
+
# cron '*/10 * * * *' # standard 5-field crontab
|
|
10
|
+
# cron '10m' # simple interval: every 10 minutes
|
|
11
|
+
# cron '10s' / '2h' / '1d' # seconds / hours / days
|
|
12
|
+
# cron '@daily' # shortcut, expands to '0 0 * * *'
|
|
13
|
+
#
|
|
14
|
+
# Cron fields support '*', 'a', 'a-b', '*/n', 'a-b/n' and comma lists,
|
|
15
|
+
# numeric values only (no 'jan'/'mon' names). Weekday 7 equals 0
|
|
16
|
+
# (Sunday). Intervals are measured from the last run - not aligned to
|
|
17
|
+
# the clock - which makes odd periods like '90m' possible and keeps
|
|
18
|
+
# restarts from re-firing (last run persists in the state file).
|
|
19
|
+
class Cron
|
|
20
|
+
SHORTCUTS ||= {
|
|
21
|
+
'@hourly' => '0 * * * *',
|
|
22
|
+
'@daily' => '0 0 * * *',
|
|
23
|
+
'@weekly' => '0 0 * * 0',
|
|
24
|
+
'@monthly' => '0 0 1 * *'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# [name, min, max] per cron field, in crontab order.
|
|
28
|
+
FIELDS ||= [
|
|
29
|
+
[:minute, 0, 59], [:hour, 0, 23], [:day, 1, 31],
|
|
30
|
+
[:month, 1, 12], [:weekday, 0, 7]
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
INTERVAL_UNITS ||= { 's' => 1, 'm' => 60, 'h' => 3600, 'd' => 86_400 }.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :source, :interval
|
|
36
|
+
|
|
37
|
+
def initialize(expr)
|
|
38
|
+
@source = expr.to_s.strip
|
|
39
|
+
|
|
40
|
+
if (m = @source.match(/\A(\d+)([smhd])\z/))
|
|
41
|
+
@interval = m[1].to_i * INTERVAL_UNITS[m[2]]
|
|
42
|
+
raise Hammer::Error, "cron: interval must be > 0 in #{@source.inspect}" if @interval.zero?
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if @source.start_with?('@') && !SHORTCUTS.key?(@source)
|
|
47
|
+
raise Hammer::Error, "cron: unknown shortcut #{@source.inspect} (valid: #{SHORTCUTS.keys.join(', ')})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
parts = (SHORTCUTS[@source] || @source).split(/\s+/)
|
|
51
|
+
unless parts.size == 5
|
|
52
|
+
raise Hammer::Error, "cron: #{@source.inspect} - expected 5 fields ('*/10 * * * *'), " \
|
|
53
|
+
"an interval ('10s', '10m', '2h', '1d') or a shortcut (#{SHORTCUTS.keys.join(', ')})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@fields = FIELDS.each_with_index.map { |(name, lo, hi), i| parse_field(name, parts[i], lo, hi) }
|
|
57
|
+
@dom_star = parts[2] == '*'
|
|
58
|
+
@dow_star = parts[4] == '*'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def interval?
|
|
62
|
+
!@interval.nil?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# True when local wall-clock time `t` (minute resolution) matches the
|
|
66
|
+
# cron expression. Vixie-cron day rule: when BOTH day-of-month and
|
|
67
|
+
# day-of-week are restricted, matching either one is enough;
|
|
68
|
+
# otherwise both must match.
|
|
69
|
+
def matches?(t)
|
|
70
|
+
return false if interval?
|
|
71
|
+
min, hour, dom, mon, dow = @fields
|
|
72
|
+
return false unless min[t.min] && hour[t.hour] && mon[t.month]
|
|
73
|
+
dom_ok = dom[t.day]
|
|
74
|
+
dow_ok = dow[t.wday]
|
|
75
|
+
@dom_star || @dow_star ? dom_ok && dow_ok : dom_ok || dow_ok
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Should the scheduler fire at `tick` (a boundary-aligned Time),
|
|
79
|
+
# given the persisted last run? Cron mode fires once per matching
|
|
80
|
+
# minute; interval mode fires when a full interval has elapsed (or
|
|
81
|
+
# the job never ran, so a fresh job gives immediate feedback on the
|
|
82
|
+
# first tick).
|
|
83
|
+
def due?(tick, last_run)
|
|
84
|
+
if interval?
|
|
85
|
+
last_run.nil? || tick.to_i - last_run.to_i >= @interval
|
|
86
|
+
else
|
|
87
|
+
matches?(tick) && (last_run.nil? || last_run.to_i / 60 < tick.to_i / 60)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Next fire time strictly after `from`. Cron mode walks minute by
|
|
92
|
+
# minute - real-world expressions match within days, and the walk is
|
|
93
|
+
# ~100 hash lookups per simulated minute - capped at 500 days so an
|
|
94
|
+
# impossible date (a Feb 30 style expression) fails loudly instead of
|
|
95
|
+
# spinning forever.
|
|
96
|
+
def next_run(from = Time.now, last_run: nil)
|
|
97
|
+
if interval?
|
|
98
|
+
return from if last_run.nil?
|
|
99
|
+
Time.at(last_run.to_i + @interval)
|
|
100
|
+
else
|
|
101
|
+
t = Time.at((from.to_i / 60 + 1) * 60)
|
|
102
|
+
(500 * 24 * 60).times do
|
|
103
|
+
return t if matches?(t)
|
|
104
|
+
t += 60
|
|
105
|
+
end
|
|
106
|
+
raise Hammer::Error, "cron: #{@source.inspect} never matches"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Parse one crontab field into a {int => true} lookup hash. Each
|
|
113
|
+
# comma-separated item is '*', 'a', 'a-b', '*/n' or 'a-b/n'.
|
|
114
|
+
# Weekday 7 is folded into 0 - both mean Sunday.
|
|
115
|
+
def parse_field(name, part, lo, hi)
|
|
116
|
+
seen = {}
|
|
117
|
+
part.split(',', -1).each do |item|
|
|
118
|
+
base, step = item.split('/', 2)
|
|
119
|
+
step = step ? parse_int(name, step, 1, hi) : 1
|
|
120
|
+
a, b =
|
|
121
|
+
if base == '*'
|
|
122
|
+
[lo, hi]
|
|
123
|
+
elsif base.include?('-')
|
|
124
|
+
base.split('-', 2).map { |v| parse_int(name, v, lo, hi) }
|
|
125
|
+
elsif item.include?('/')
|
|
126
|
+
raise Hammer::Error, "cron: #{name} - step needs '*' or a range: #{item.inspect}"
|
|
127
|
+
else
|
|
128
|
+
v = parse_int(name, base, lo, hi)
|
|
129
|
+
[v, v]
|
|
130
|
+
end
|
|
131
|
+
raise Hammer::Error, "cron: #{name} - inverted range #{item.inspect}" if a > b
|
|
132
|
+
(a..b).step(step) { |v| seen[name == :weekday && v == 7 ? 0 : v] = true }
|
|
133
|
+
end
|
|
134
|
+
seen
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Strict integer within [lo, hi], or a Hammer::Error naming the field
|
|
138
|
+
# so the message points straight at the typo.
|
|
139
|
+
def parse_int(name, str, lo, hi)
|
|
140
|
+
v = begin
|
|
141
|
+
Integer(str, 10)
|
|
142
|
+
rescue ArgumentError, TypeError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
unless v && v.between?(lo, hi)
|
|
146
|
+
raise Hammer::Error, "cron: bad #{name} value #{str.inspect} (allowed #{lo}-#{hi})"
|
|
147
|
+
end
|
|
148
|
+
v
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|