asgard 0.3.3 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f88b550d323b26b075a45f6a7d6e8781b7d54d062c1f724de25b2d253d2f3e4
4
- data.tar.gz: 5c2a05803003793e14ad08f0453d0a549cb6f2f0202b31c38c600303a539d66b
3
+ metadata.gz: 0fd4fe5903f93e062c0f515b3f6a49b859671c679b066003c04f2012eaf7176c
4
+ data.tar.gz: 5af1e1b65fc854258a68ec98a220d8260720474d9d84a7644cecec2fc054889f
5
5
  SHA512:
6
- metadata.gz: d9f2d214246e0960b2a2a4ca790818a8f57edfd45c17f0fca6edf1bcf486cc096d0331818d899c5ca929f9fcec404329744403a0f03aa2fe0faafb70b13ae172
7
- data.tar.gz: e35a52164438b5734cdf86ee4f7210e2169edb391e8478cddd1548698691d3bddea5d703ef113e34bcdd1caeb0041d9851ec94b8ee7840b9115a0ca821f03f49
6
+ metadata.gz: eb251bfc200af2b94dc66e6247475f2a4c0d2a474d0f1d4b165caf05250c8dbb55bd31295b2c0b7e963aeace88f2b8f2c2ecb8751271a522ea5c7a99486bccfe
7
+ data.tar.gz: c2540f86218ca5fe471ca276081d23224b2bf4f5784862915bf69f21c83acf664ef57f051bb4587a7104d0bb0bf82e768f28acc9b5f3e2cc32d15eac902dbf94
data/.reek.yml CHANGED
@@ -44,6 +44,7 @@ detectors:
44
44
  Attribute:
45
45
  exclude:
46
46
  - Asgard::Doctor::ImportTracer#doctor # needs to stay settable/resettable per doctor run
47
+ - Asgard::Schedule#runner # test seam: tests swap in a recording runner, nil restores RUNNER
47
48
 
48
49
  BooleanParameter:
49
50
  exclude:
@@ -55,6 +56,7 @@ detectors:
55
56
  exclude:
56
57
  - Asgard::Shell#sh
57
58
  - Asgard::Shell#shebang
59
+ - Asgard::Schedule#entry_name # as: is the user's explicit override of the derived name
58
60
 
59
61
  DuplicateMethodCall:
60
62
  exclude:
@@ -66,6 +68,7 @@ detectors:
66
68
  - Kernel#import_up # "not found" warned from two independent branches
67
69
  - Asgard#self.run! # Base.subclasses snapshot diff; e.message in separate rescues
68
70
  - Asgard::Base#tree # say "\n": intentional blank line before and after the tree
71
+ - Asgard::Schedule::Launchd#unload # loaded?(name): check, then poll until bootout finishes
69
72
 
70
73
  FeatureEnvy:
71
74
  exclude:
@@ -86,6 +89,7 @@ detectors:
86
89
  exclude:
87
90
  - Asgard::Base::DependencyGraph#_normalize_pending_deps # respond_to?(:call) distinguishes a Proc from an Array/Symbol — no polymorphic alternative over Ruby's own built-in types
88
91
  - Asgard::Base::DependencyGraph#_resolve_lazy_deps! # same check
92
+ - Asgard::Schedule#seconds # duck-types ActiveSupport::Duration (in_seconds) without depending on it
89
93
 
90
94
  InstanceVariableAssumption:
91
95
  exclude:
@@ -100,6 +104,8 @@ detectors:
100
104
  - Asgard::Doctor::Report#print_task_sections
101
105
  - Asgard::Doctor::TaskSections#class_task_file_map
102
106
  - Asgard::Doctor::TaskSections#override_count
107
+ - Asgard::Schedule::Commands#preview # specs x files per spec
108
+ - Asgard::Schedule::Launchd#self.calendar_intervals # times x weekdays
103
109
 
104
110
  UtilityFunction:
105
111
  exclude:
@@ -107,6 +113,39 @@ detectors:
107
113
  - Asgard::Base::Dispatch#dep_results # thread-local by design, not self — that's the whole point
108
114
  - Asgard::Base::Dispatch#with_dep_results # same: stashes results on Thread.current, not self
109
115
  - Asgard::Shell#shell_argv # extracted pure on purpose so it's testable without invoking system/exec
116
+ - Asgard::Schedule::DSL#schedule # class-level DSL delegating to the Schedule registry
117
+ - Asgard::Schedule::Commands#schedules # Thor no_commands helpers: reads of the registry / CWD shared by the subcommands
118
+ - Asgard::Schedule::Commands#schedule_root
119
+ - Asgard::Schedule::Commands#schedule_summary
120
+
121
+ # Asgard::Schedule: the backends implement a fixed API (see
122
+ # lib/asgard/schedule/declaration.rb) plus pure, separately tested helpers,
123
+ # and the keyword parameters below mirror launchd/systemd job fields and the
124
+ # `schedule` DSL's own keywords.
125
+ DataClump:
126
+ exclude:
127
+ - Asgard::Schedule # at:/on: and task/args are the declaration's vocabulary
128
+
129
+ LongParameterList:
130
+ exclude:
131
+ - Asgard::Schedule#normalize # the `schedule` DSL keywords
132
+ - Asgard::Schedule#describe
133
+ - Asgard::Schedule#program_arguments
134
+ - Asgard::Schedule::Launchd#self.plist # one keyword per plist field
135
+ - Asgard::Schedule::Systemd#self.service_unit # one keyword per unit field
136
+ - Asgard::Schedule::Systemd#self.timer_unit
137
+ - Asgard::Schedule::Systemd#initialize # injectable home/runner/env/user for tests
138
+
139
+ RepeatedConditional:
140
+ exclude:
141
+ - Asgard::Schedule::Launchd # loaded?(name) guards distinct launchctl transitions
142
+
143
+ TooManyMethods:
144
+ exclude:
145
+ - Asgard::Schedule::Commands # one method per subcommand plus its helpers
146
+ - Asgard::Schedule::Launchd # backend API + pure helpers
147
+ - Asgard::Schedule::Systemd
148
+
110
149
 
111
150
  exclude_paths:
112
151
  - test
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-09-26
9
+
10
+ ### Added
11
+
12
+ - **Scheduled tasks, built in** — `schedule :task, at: "17:30", on: :weekdays`
13
+ (or `every: 3600`) at class level in a `.loki` declares a periodic run, and
14
+ `asgard schedule preview|install|list|start|stop|trigger|log|remove`
15
+ manages the entries under launchd (macOS) or systemd user timers (Linux).
16
+ Missed calendar runs fire on wake; a project `.envrc` is loaded at run time
17
+ through `direnv exec`. Promoted from the standalone `dev/schedule.loki`;
18
+ job labels and paths are unchanged, so entries it installed are still
19
+ recognized. ActiveSupport is no longer required: `every:` takes seconds or
20
+ any object with `in_seconds`. The command is registered as the gem-owned
21
+ `_schedule` and mapped to `schedule`. See
22
+ [Scheduled Tasks](https://madbomber.github.io/asgard/schedule/).
23
+ - **`Asgard::Schedule::Error`** (subclass of `Asgard::Error`) — raised when
24
+ `launchctl`/`systemctl` fails; `Asgard.run!` reports it as a one-line
25
+ `asgard: ...` message.
26
+
27
+ ### Changed
28
+
29
+ - **Upgrading from `dev/schedule.loki`:** remove `import_up "dev/schedule.loki"`
30
+ from your `.loki`. Its `Tasks.schedule` overrides the built-in one and
31
+ records declarations where `asgard schedule` never looks.
32
+ - `Asgard::Error` and `Asgard::CircularDependencyError` now live in
33
+ `lib/asgard/errors.rb`, loaded first. No change to the classes themselves.
34
+ - The gem's own test run (`test_check`, `test_verbose`) loads every
35
+ `test/test_*.rb` in one process, so coverage is measured across the whole
36
+ suite.
37
+
38
+ ### Fixed
39
+
40
+ - **`asgard <task>` now exits 1 when the task returns `:fail`** — quality-gate
41
+ tasks (`*_check`) signal failure by returning `:fail`; previously that result
42
+ was discarded and asgard always exited 0, so callers relying on the exit
43
+ status (CI, cross-repo runners like `ws_quality.loki`) saw every individual
44
+ gate as passing. The aggregate `quality` task was unaffected (it aborts
45
+ itself). Only a top-level result of exactly `:fail` maps to exit 1; `:warn`
46
+ and `:skip` remain non-blocking.
47
+
8
48
  ## [0.3.3] - 2026-08-27
9
49
 
10
50
  ### Added
@@ -105,7 +145,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
105
145
 
106
146
  ### Added (continued)
107
147
 
108
- - **`--doctor` built-in CLI flag** — diagnoses `.loki` resolution, import chains, and task definitions for the current directory, then exits. Handled directly in `Asgard.run!` before the `.loki` file is loaded (same pattern as `--version`), so it keeps working in exactly the situations that would otherwise abort the whole process: a broken `.loki` file, a circular or undefined dependency, or a task silently redefined by a later `def`. Backed by the new `Asgard::Doctor` class. The report includes a "Tasks by file" listing: every command grouped by the file it's defined in, printed as `relative/path:line` so an editor can jump straight to it. A task name defined at more than one location gets every definition annotated inline — the earlier one(s) as `OVERRIDDEN by <file>:<line> — never callable`, the winning (last) one as `active — redefines <file>:<line>` — replacing the old flat "Tasks#x redefined" summary line with an annotation right where the problem is. See [API Reference](docs/api.md#asgarddoctor).
148
+ - **`--doctor` built-in CLI flag** — diagnoses `.loki` resolution, import chains, and task definitions for the current directory, then exits. Handled directly in `Asgard.run!` before the `.loki` file is loaded (same pattern as `--version`), so it keeps working in exactly the situations that would otherwise abort the whole process: a broken `.loki` file, a circular or undefined dependency, or a task silently redefined by a later `def`. Backed by the new `Asgard::Doctor` class. The report includes a "Tasks by file" listing: every command grouped by the file it's defined in, printed as `relative/path:line` so an editor can jump straight to it. A task name defined at more than one location gets every definition annotated inline — the earlier one(s) as `OVERRIDDEN by <file>:<line> — never callable`, the winning (last) one as `active — redefines <file>:<line>` — replacing the old flat "Tasks#x redefined" summary line with an annotation right where the problem is. See [API Reference](https://madbomber.github.io/asgard/api/#asgarddoctor).
109
149
  - **Flay and Reek quality gates** — `flay_check` checks for structural code duplication (mass ≥ 150); `reek` checks code smells. Both run as part of `quality` alongside `test`, `rubocop`, and `flog_check`. A `.reek.yml` tunes several detectors to this codebase's conventions (no doc-comment requirement, short variable names, disabled `TooManyStatements`, etc), plus per-method `exclude:` entries (generated with `reek --todo` and hand-curated) that grandfather specific reviewed smells at specific methods — precise enough that a genuinely new smell still fails the gate even at an already-reviewed method, unlike a per-file count.
110
150
  - **`test_verbose` task** — runs the test suite with Minitest's verbose (`-v`) output.
111
151
  - **Colorized quality gate summary** — `quality`'s final report now prints a green/red PASS/FAIL badge per gate plus a passed/failed tally, via a shared `print_quality_summary` helper.
@@ -135,7 +175,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
135
175
 
136
176
  ### Added (continued 3)
137
177
 
138
- - **`depends_on` accepts a Proc/lambda in addition to a fixed list** — a sole callable defers resolution to `validate_deps!` (once, right after every `.loki` file has loaded) instead of resolving immediately when `depends_on` itself is evaluated. This solves the "load order matters" problem for a dependency list that can't be known upfront — e.g. "every task whose name ends in `_check`," discovered across several files including ones imported conditionally (`import "quality_rails.loki" if defined?(Rails)`). The Proc must return the same shape the splat form would receive (an array of stages, each a `Symbol` or `Array`); it's written directly in the class body, so it lexically captures that class as `self` and can call `all_commands` bare. A Proc that raises is re-raised as `Asgard::Error` naming the task it was declared for; a Proc that resolves to an undefined task or a cycle is still caught by the existing startup validation, since the resolved result is checked exactly like a plain array. See [Dynamic Dependencies](docs/dependencies.md#dynamic-dependencies-proc-form).
178
+ - **`depends_on` accepts a Proc/lambda in addition to a fixed list** — a sole callable defers resolution to `validate_deps!` (once, right after every `.loki` file has loaded) instead of resolving immediately when `depends_on` itself is evaluated. This solves the "load order matters" problem for a dependency list that can't be known upfront — e.g. "every task whose name ends in `_check`," discovered across several files including ones imported conditionally (`import "quality_rails.loki" if defined?(Rails)`). The Proc must return the same shape the splat form would receive (an array of stages, each a `Symbol` or `Array`); it's written directly in the class body, so it lexically captures that class as `self` and can call `all_commands` bare. A Proc that raises is re-raised as `Asgard::Error` naming the task it was declared for; a Proc that resolves to an undefined task or a cycle is still caught by the existing startup validation, since the resolved result is checked exactly like a plain array. See [Dynamic Dependencies](https://madbomber.github.io/asgard/dependencies/#dynamic-dependencies-proc-block-form).
139
179
  - **`bundler_audit_check` task** — runs `bundle-audit check --update` against `Gemfile.lock` (new dev dependency `bundler-audit`); reports `FAIL` on any known vulnerability, since this is a security gate, not a suggestion.
140
180
  - **`quality_rails.loki`** — imported by `.loki` only when `Rails` is defined; currently ships `brakeman_check`, a Rails security-scan example. Needs no wiring into `quality`'s dependency list — `quality`'s `depends_on` Proc discovers it automatically once it's loaded.
141
181
 
@@ -161,7 +201,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
161
201
 
162
202
  ### Added (continued 5)
163
203
 
164
- - **`sh(script, exec: true)`** — hands the command the asgard process itself via `Kernel.exec` instead of forking. For a task's final, long-running command (a dev server, a REPL) this replaces the ruby process outright, so nothing sits resident in memory behind it and Ctrl-C is handled directly by the command instead of unwinding back through asgard. `doc_tasks.loki`'s `doc_server` task (`sh "mkdocs serve", exec: true`) is the motivating example. See [Shell Helpers](docs/shell.md#handing-off-with-exec).
204
+ - **`sh(script, exec: true)`** — hands the command the asgard process itself via `Kernel.exec` instead of forking. For a task's final, long-running command (a dev server, a REPL) this replaces the ruby process outright, so nothing sits resident in memory behind it and Ctrl-C is handled directly by the command instead of unwinding back through asgard. `doc_tasks.loki`'s `doc_server` task (`sh "mkdocs serve", exec: true`) is the motivating example. See [Shell Helpers](https://madbomber.github.io/asgard/shell/#handing-off-with-exec).
165
205
  - **`bootstrap` and `env_info` tasks in `kitchen_sink.loki`** — demonstrate `sh` with a multi-line heredoc (routed through `bash -c`) and a single-line command, respectively.
166
206
 
167
207
  ### Fixed (continued 3)
@@ -170,7 +210,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
170
210
 
171
211
  ### Added (continued 6)
172
212
 
173
- - **`depends_on` accepts a block in addition to a Proc/lambda** — `depends_on { ... }` (or `depends_on do ... end` for a block spanning multiple statements) defers resolution to `validate_deps!` exactly like the existing sole-Proc/lambda form; the two are interchangeable. `depends_on` still accepts task arguments *or* a block, never both — combining them raises `Asgard::Error`. See [Dynamic Dependencies](docs/dependencies.md#dynamic-dependencies-proc-block-form).
213
+ - **`depends_on` accepts a block in addition to a Proc/lambda** — `depends_on { ... }` (or `depends_on do ... end` for a block spanning multiple statements) defers resolution to `validate_deps!` exactly like the existing sole-Proc/lambda form; the two are interchangeable. `depends_on` still accepts task arguments *or* a block, never both — combining them raises `Asgard::Error`. See [Dynamic Dependencies](https://madbomber.github.io/asgard/dependencies/#dynamic-dependencies-proc-block-form).
174
214
  - **The resolved Proc/lambda/block result is now shape-validated** — once `validate_deps!` calls it, the return value must be an `Array` of stages, each a `Symbol`/`String` (sequential) or an `Array` of `Symbol`/`String` (parallel group), nested no deeper than that. A bad shape (wrong type, an invalid stage, a non-Symbol/String leaf, or nesting more than one level deep) now raises `Asgard::Error` naming the task and the offending value, instead of failing later with an opaque `NoMethodError`.
175
215
  - **`examples/depends_on_block/good/` and `examples/depends_on_block/bad/`** — two self-contained example projects (each its own `.loki` root, isolated from the main `examples/` tree) demonstrating the block form: `good/` covers single-line `{ ... }`, `do...end`, and a mixed sequential+parallel shape; `bad/` demonstrates the double-wrapped-array mistake that the new shape validation catches, with the exact `Asgard::Error` message it produces.
176
216
 
@@ -251,7 +291,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
251
291
  - 100% test coverage enforced via SimpleCov (95% minimum threshold)
252
292
  - Quality task in `.loki` runs flog after tests
253
293
 
254
- [0.3.0]: https://github.com/MadBomber/asgard/compare/v0.2.2...HEAD
294
+ [0.4.0]: https://github.com/MadBomber/asgard/compare/v0.3.3...v0.4.0
295
+ [0.3.3]: https://github.com/MadBomber/asgard/compare/v0.2.0...v0.3.3
255
296
  [0.2.0]: https://github.com/MadBomber/asgard/compare/v0.1.2...v0.2.0
256
297
  [0.1.2]: https://github.com/MadBomber/asgard/compare/v0.1.1...v0.1.2
257
298
  [0.1.1]: https://github.com/MadBomber/asgard/compare/v0.1.0...v0.1.1
data/CLAUDE.md CHANGED
@@ -21,7 +21,9 @@ asgard quality
21
21
  asgard release
22
22
  ```
23
23
 
24
- Single test: `ruby -Ilib:test test/test_asgard.rb`
24
+ Whole suite (one process, so SimpleCov sees everything): `ruby -Ilib:test -e 'Dir["test/test_*.rb"].each { |f| require File.expand_path(f) }'`
25
+
26
+ Single file: `ruby -Ilib:test test/test_asgard.rb` (a single file alone will fall below the coverage minimum)
25
27
 
26
28
  ## Architecture
27
29
 
@@ -41,6 +43,10 @@ Single test: `ruby -Ilib:test test/test_asgard.rb`
41
43
  | `lib/asgard/base.rb` | DSL engine; inherits Thor, includes Shell |
42
44
  | `lib/asgard/shell.rb` | `sh` / `shebang` helpers |
43
45
  | `lib/asgard/tasks.rb` | `class Tasks < Asgard::Base` — the convention class users reopen; also holds gem-owned built-in tasks |
46
+ | `lib/asgard/schedule.rb` | `Asgard::Schedule` — the `schedule` DSL registry; requires the files below |
47
+ | `lib/asgard/schedule/declaration.rb` | Pure declaration validation + the backend API contract |
48
+ | `lib/asgard/schedule/launchd.rb`, `systemd.rb` | Platform backends; shell out through an injectable runner |
49
+ | `lib/asgard/schedule/commands.rb` | `asgard schedule ...` subcommands (registered as `_schedule`, mapped to `schedule`) |
44
50
 
45
51
  ### Naming Convention for Gem-Owned Methods
46
52
 
@@ -100,7 +106,7 @@ Asgard adds the following `module_function` methods to `Kernel`, making them ava
100
106
 
101
107
  ## Testing
102
108
 
103
- All tests are in `test/test_asgard.rb` (one file, ~11 named classes). SimpleCov minimum is 95%; the Rakefile configures this with a prelude that loads coverage before the library.
109
+ Engine tests are in `test/test_asgard.rb`; scheduling tests are in `test/test_schedule.rb`, which uses a `FakeRunner` (records launchctl/systemctl argv) so both backends test on any platform. SimpleCov minimum is 95%; the Rakefile configures this with a prelude that loads coverage before the library.
104
110
 
105
111
  Key test patterns: tests frequently subclass `Asgard::Base` directly (not `Tasks`) to test the engine in isolation, and use `capture_io` for output assertions.
106
112
 
data/README.md CHANGED
@@ -23,6 +23,7 @@
23
23
  - <strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code><br>
24
24
  - <strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories<br>
25
25
  - <strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files, loaded via <code>import</code> from your <code>.loki</code><br>
26
+ - <strong>Scheduled Tasks</strong> — run any task periodically under launchd (macOS) or systemd timers (Linux) with <code>schedule</code> and <code>asgard schedule install</code><br>
26
27
  - <strong>Built-in Flags</strong> — <code>--debug</code>, <code>--verbose</code>, <code>--version</code>, and <code>--doctor</code> built-in class options; header/footer DSL for static help text<br>
27
28
  </td>
28
29
  </tr>
@@ -605,6 +606,39 @@ See [`examples/server_subcommands.loki`](examples/server_subcommands.loki) and [
605
606
 
606
607
  ---
607
608
 
609
+ ## Scheduled tasks
610
+
611
+ Any task can run on a schedule under the platform's own scheduler:
612
+ **launchd** on macOS, **systemd user timers** on Linux. Declare schedules at
613
+ class level in `.loki`:
614
+
615
+ ```ruby
616
+ class Tasks
617
+ schedule :daily_summary, at: "17:30", on: :weekdays
618
+ schedule :backup, at: %w[02:00 14:00] # on: defaults to :daily
619
+ schedule :sync, every: 3600 # seconds
620
+ schedule :report, options: "--period week", at: "16:00", on: :friday
621
+ end
622
+ ```
623
+
624
+ Then manage them with the built-in `schedule` command:
625
+
626
+ ```bash
627
+ asgard schedule preview # job files install would write
628
+ asgard schedule install # load declared entries; drop undeclared ones
629
+ asgard schedule list # installed entries, state, last exit status
630
+ asgard schedule stop NAME # stop one entry (persists across reboots and installs)
631
+ asgard schedule start NAME # start a stopped entry, or install just this one
632
+ asgard schedule trigger NAME # run an installed entry now
633
+ asgard schedule log NAME [-f] # print (or follow) the entry's log
634
+ asgard schedule remove # remove all of this project's entries
635
+ ```
636
+
637
+ Jobs run `asgard <task> [options]` from the directory holding `.loki`, with
638
+ the `PATH` captured at install time; a project `.envrc` is loaded at run time
639
+ through `direnv exec`. See [Scheduled Tasks](https://madbomber.github.io/asgard/schedule/)
640
+ for every keyword, entry naming, and file locations.
641
+
608
642
  ## `method_option` types reference
609
643
 
610
644
  | Type | CLI example | Ruby value |
data/docs/api.md CHANGED
@@ -27,6 +27,8 @@ abort "asgard: unknown command '#{argv.first}'" if argv.first&.start_with?("_")
27
27
 
28
28
  After loading task files, it calls `Tasks.validate_deps!` (circular dependency check) and `Tasks._reset_ran!` (clears per-invocation deduplication state) before starting Thor.
29
29
 
30
+ If the dispatched task returns exactly `:fail`, `run!` exits with status 1 (the quality-gate convention). Any other return value, including `:warn` and `:skip`, exits 0.
31
+
30
32
  ---
31
33
 
32
34
  ## Kernel Methods
@@ -198,6 +200,8 @@ See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-block-form)
198
200
  | `class_option :doctor` | class option | `--doctor` flag. Handled by `Asgard.run!` before the `.loki` file is loaded — runs `Asgard::Doctor.new.run` and exits. `no_negate :doctor` suppresses the `[--no-doctor]` / `[--skip-doctor]` help entries. |
199
201
  | `debug?` | Kernel module function | Returns `$DEBUG`. Available everywhere via `Kernel`. |
200
202
  | `verbose?` | Kernel module function | Returns `$VERBOSE`. Available everywhere via `Kernel`. |
203
+ | `schedule` | class method (DSL) | `schedule :task, at:/every:, ...` — declares a scheduled entry. Extended from `Asgard::Schedule::DSL`; see [`Asgard::Schedule`](#asgardschedule). |
204
+ | `_schedule` | subcommand | `asgard schedule SUBCOMMAND`, backed by `Asgard::Schedule::Commands`. `map "schedule" => :_schedule` keeps the typed name `schedule`, and a user task named `schedule` cannot shadow it. |
201
205
 
202
206
  ---
203
207
 
@@ -267,6 +271,22 @@ The warning is a shadowed ancestor `.loki` marker one directory further up the t
267
271
 
268
272
  ---
269
273
 
274
+ ## `Asgard::Schedule`
275
+
276
+ Scheduled tasks. The user-facing guide is [Scheduled Tasks](schedule.md); this section covers the Ruby API.
277
+
278
+ | Method | Signature | Description |
279
+ |---|---|---|
280
+ | `declare` | `Asgard::Schedule.declare(task, **settings) → Hash` | What `Tasks.schedule` calls. Validates via `normalize` and records the entry. Redeclaring an identical entry is a no-op; a different entry under the same name raises `ArgumentError`. |
281
+ | `declarations` | `Asgard::Schedule.declarations → Hash` | Declared entries for this run, keyed by entry name. |
282
+ | `normalize` | `Asgard::Schedule.normalize(task, options: nil, at: nil, on: :daily, every: nil, env: {}, as: nil) → Hash` | Validates one declaration and returns `{ name:, task:, args:, at:, on:, every:, env: }`. Pure. |
283
+ | `backend_class` | `Asgard::Schedule.backend_class(platform = RUBY_PLATFORM)` | `Launchd` on darwin, `Systemd` on linux; raises `Asgard::Schedule::Error` elsewhere. |
284
+ | `runner` / `runner=` | `Asgard::Schedule.runner → #call` | The command runner new backends use: `argv` in, `[output, success?]` out. Defaults to `RUNNER` (`Open3.capture2e`); tests assign a recording runner, and `nil` restores the default. |
285
+
286
+ Both backends, `Asgard::Schedule::Launchd` and `Asgard::Schedule::Systemd`, implement the same instance API: `files`, `install`, `uninstall`, `start`, `stop`, `trigger`, `installed_names`, `status`, `log_path`, `notes`. Each class also exposes the pure helpers that build its job files (`Launchd.plist`, `Systemd.service_unit`, `Systemd.timer_unit`, ...), so they can be tested without a scheduler.
287
+
288
+ ---
289
+
270
290
  ## `Asgard::Base` Internal Class Methods
271
291
 
272
292
  These are implementation details exposed for extensibility. Prefer the DSL methods above in normal use.
@@ -298,6 +318,7 @@ These are implementation details exposed for extensibility. Prefer the DSL metho
298
318
  | Class | Superclass | Description |
299
319
  |---|---|---|
300
320
  | `Asgard::Error` | `StandardError` | Base error class for all Asgard errors. |
321
+ | `Asgard::Schedule::Error` | `Asgard::Error` | Raised when a `launchctl`/`systemctl` command fails, or on an unsupported platform. `run!` reports it as `asgard: <message>`. |
301
322
  | `Asgard::CircularDependencyError` | `Asgard::Error` | Raised by `validate_deps!` when a cycle is detected in the dependency graph. `run!` catches this and calls `abort` with a clean message. |
302
323
 
303
324
  ```ruby
data/docs/changelog.md CHANGED
@@ -1,134 +1 @@
1
- # Changelog
2
-
3
- All notable changes to Asgard are documented here.
4
-
5
- The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Asgard adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ---
8
-
9
- ## [Unreleased]
10
-
11
- ### Added
12
-
13
- - **`--doctor` built-in CLI flag** — diagnoses `.loki` resolution, import chains, and task definitions for the current directory. Handled directly in `Asgard.run!` (same pattern as `--version`), so it works even when a broken file, a circular/undefined dependency, or a silently redefined task would otherwise abort the whole process. Backed by the new `Asgard::Doctor` class. Includes a "Tasks by file" listing — every command grouped by the file it's defined in, as `file:line`, with any silently-overridden task annotated inline (`OVERRIDDEN by ...` / `active — redefines ...`). See [API Reference](api.md#asgarddoctor).
14
- - **`helper` DSL method** — defines a method available in both class context (e.g. inside `header`) and instance context (inside task methods) with a single declaration. Eliminates the manual `def self.name` + `no_commands { private def name = self.class.name }` boilerplate. Supports positional arguments, keyword arguments, and block arguments. See [Helper Methods](helpers.md).
15
- - **Flay and Reek quality gates** — `flay_check` checks for structural duplication (mass ≥ 150); `reek` checks code smells. Reviewed findings are grandfathered precisely, per method and detector, via `reek --todo`-generated `exclude:` entries in `.reek.yml` — a genuinely new smell still fails the gate even at an already-reviewed method.
16
- - **`test_verbose`, `console` tasks** — verbose test output and an IRB console with the gem loaded.
17
- - **`git.loki`** — per-repo `push`/`pull`/`fetch` tasks.
18
- - **`typos_check` / `typos_fix` tasks** — spell-checking via the external `typos` CLI (`brew install typos-cli`). Reports `SKIP` (not a failure) if `typos` isn't installed, with a one-line install hint.
19
- - **`fasterer_check` task** — performance-idiom suggestions from the `fasterer` gem, reported as `WARN` (non-blocking).
20
- - **`SKIP` / `WARN` quality-gate statuses** — alongside `PASS`/`FAIL`; only `FAIL` blocks `quality`.
21
- - Every quality gate now writes full detail to a `<gate>_output.txt` file (gitignored) and prints just a one-line summary to stdout.
22
- - **`asgard tree`** now shows the project header/footer, matching `asgard help`.
23
- - **`bundler_audit_check` task** — `bundle-audit check --update` against `Gemfile.lock`; `FAIL` on any known vulnerability.
24
- - **`quality_rails.loki`** — imported only when `Rails` is defined; ships `brakeman_check` as a Rails security-scan example. No wiring needed — `quality` discovers it automatically (see `depends_on` below).
25
- - **`depends_on` accepts a Proc/lambda**, not just a fixed list — resolved once, in `validate_deps!`, after every `.loki` file has loaded, instead of immediately. Solves "load order matters" for a dependency list that can't be known upfront, e.g. every task ending in `_check` across several files. See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-block-form).
26
- - **`depends_on` also accepts a block** — `depends_on { ... }` or `depends_on do ... end`, interchangeable with the Proc/lambda form above. Task arguments and a block can't be combined; doing so raises `Asgard::Error`.
27
- - **The Proc/lambda/block result is now shape-validated** once resolved — it must be an `Array` of `Symbol`/`String` (sequential) or `Array` of `Symbol`/`String` (parallel group) stages, nested no deeper. A bad shape raises a clear `Asgard::Error` naming the task and the offending value instead of a raw `NoMethodError`. See `examples/depends_on_block/{good,bad}/`.
28
-
29
- ### Changed
30
-
31
- - **`quality` task** — discovers every `*_check` task at run time (via a `depends_on` Proc) rather than a fixed list, and runs them all in parallel with a colorized PASS/FAIL/WARN/SKIP summary and tally.
32
- - **`test`, `rubocop`, `reek` renamed to `test_check`, `rubocop_check`, `reek_check`** — consistency with the other gates is what makes `quality`'s automatic discovery possible.
33
- - **`release` task** — prompts for confirmation unless `-y`/`--yes` is passed.
34
- - **`Asgard::Base` and `Asgard::Doctor` split into mixins** — `lib/asgard/base/{registry,dependency_graph,task_dsl,dispatch}.rb` and `lib/asgard/doctor/{task_sections,report}.rb`. No behavior change; drops both classes' Reek `TooManyMethods`/`TooManyInstanceVariables` warnings to zero.
35
-
36
- ### Fixed
37
-
38
- - **`bin/asgard`** — switched from `require "asgard"` to `require_relative "../lib/asgard"` so the executable always loads the library shipped alongside it, instead of whatever `asgard` gem happens to be installed separately.
39
-
40
- ### Removed
41
-
42
- - **`reek_baseline` / `ensure_quality_dir` tasks and `.quality/`** — superseded by precise per-method `exclude:` entries in `.reek.yml` (see the Reek gate entry above).
43
- - **`var` DSL method** — replaced by native Ruby class variables. Use `@@name ||= "value".freeze` in the class body. Class variables are visible in all task instance methods and in subcommand subclasses, making them the correct tool for shared configuration in a Thor-based task runner. See [Variables](variables.md).
44
-
45
- ## [0.2.0] — 2026-05-29
46
-
47
- ### Changed
48
-
49
- - `*.loki` files are no longer auto-loaded by default. Pass `--auto-load` to `asgard` to load all `*.loki` files from the project root alphabetically before `.loki`. This is a breaking change for projects using the multi-file layout.
50
- - Added `--auto-load` as a built-in CLI flag in `Tasks`, visible in `asgard help`
51
-
52
- ---
53
-
54
- ## [0.1.2] — 2026-05-29
55
-
56
- ### Added
57
-
58
- - `--version` built-in CLI flag — prints `Asgard::VERSION` and exits; implemented as a `_`-prefixed method in `Tasks` per the gem-owned naming convention
59
- - `--debug` and `--verbose` built-in `class_option` declarations on `Tasks` — set `$DEBUG`/`$VERBOSE` before any task runs via the `invoke_command` hook in `Asgard::Base`
60
- - `debug?` and `verbose?` private predicate helpers on `Tasks` — thin wrappers around `$DEBUG` and `$VERBOSE` for use inside task bodies
61
- - `_` prefix convention for gem-owned methods in `Tasks` — built-in methods use `_` prefix to distinguish them from user-defined tasks
62
- - `run!` guards against direct invocation of `_`-prefixed commands with a clean error message and exit 1
63
- - `examples/` directory with working `.loki` files:
64
- - `kitchen_sink.loki` — demonstrates the full Thor DSL (all option types, `long_desc`, `class_option`, `default_task`, `map`, `depends_on`, `var`, `no_commands`, `private`)
65
- - `server_subcommands.loki` — subcommand group for server management
66
- - `db_subcommands.loki` — subcommand group for database management with `depends_on` chaining
67
- - `concurrent.loki` — demonstrates parallel task execution with interleaved thread output
68
- - README sections: Helper methods, Subcommands, Thor wrapper callout
69
-
70
- ### Fixed
71
-
72
- - Replaced `warn`/`exit 1` with `abort` throughout `run!` — `Kernel#warn` is silenced when `$VERBOSE = nil`, which is the default in Ruby 4.0; `abort` writes to `$stderr` regardless
73
-
74
- ### Changed
75
-
76
- - `--debug` and `--verbose` promoted from mapped tasks to `class_option` — they now work as modifiers alongside other commands (e.g. `asgard build --debug`) rather than as standalone commands
77
- - Removed all references to `just` task runner and `recipe` terminology; Asgard uses "task" throughout
78
- - `depends_on` parameter renamed from `*recipes` to `*tasks` for consistency
79
-
80
- ---
81
-
82
- ## [0.1.1] — 2026-05-28
83
-
84
- ### Added
85
-
86
- - Parallel dependency execution — wrap deps in an array to run them concurrently:
87
- `depends_on [:build, :lint]` or `depends_on :setup, [:build, :lint], :deploy`
88
- - `Asgard.run!(argv)` — single entry point encapsulating find, load, validate, and start
89
- - `Asgard.load_loki(dir)` — auto-loads all `*.loki` files in a directory alphabetically
90
- - `Tasks` class pre-defined by the gem (`class Tasks < Asgard::Base`) — task files reopen it without restating the superclass
91
- - `lib/asgard/tasks.rb` — ships the pre-defined `Tasks` class
92
-
93
- ### Changed
94
-
95
- - Replaced `SimpleFlow` dependency with `Dagwood` — purpose-built DAG library with no extra dependencies and no Ruby 4 compatibility issues
96
- - `bin/asgard` simplified to two lines: `require "asgard"` + `Asgard.run!(ARGV)`
97
- - Task file convention: `.loki` is the project root marker and entry point; `*.loki` files each reopen `class Tasks` and are auto-loaded before `.loki`
98
- - `Asgard.find_task_files` renamed to `Asgard.find_task_file` (singular — only `.loki` is the entry point)
99
- - `depends_on` now accepts mixed sequential/parallel stages; bare symbols run sequentially, arrays within the splat run in parallel
100
- - `run!` handles its own errors — missing `.loki` and circular dependencies produce a clean one-line message and exit 1 rather than a backtrace
101
- - Thread-safe dep deduplication via class-level `_ran_tasks` Set + Mutex replaces Thor's `@_invocations`
102
- - Removed `import` macro — task files use Ruby class reopening instead of modules
103
-
104
- ### Removed
105
-
106
- - `SimpleFlow` dependency (replaced by `Dagwood`)
107
- - `logger` gem workaround (was only needed for SimpleFlow on Ruby 4)
108
- - `*.loki` glob fallback in `find_task_file` — only `.loki` is the auto-discovered entry point
109
-
110
- ---
111
-
112
- ## [0.1.0] — 2026-05-28
113
-
114
- ### Added
115
-
116
- - `Asgard::Base` — Thor subclass providing the task DSL
117
- - `depends_on` — declare task dependencies; dependencies run at most once per invocation
118
- - `var` — declare static or lazy-evaluated variables available to all tasks
119
- - `import` — flat-merge a task module into the current class
120
- - `dotenv` — load a `.env` file into the environment
121
- - `sh` — run a shell command or multiline heredoc script; exits with the command's status on failure
122
- - `shebang` — write a script body to a tempfile and execute it with a given interpreter (`:python3`, `:node`, `:ruby`, `:perl`, `:bash`, `:sh`, or any custom interpreter)
123
- - `Asgard.find_task_files` — search current directory and ancestors for task files
124
- - Task file resolution: `.loki` takes priority; falls back to all `*.loki` files sorted alphabetically
125
- - `asgard` executable — finds task files, validates dependency graph, dispatches via Thor
126
- - Circular dependency detection via `SimpleFlow::DependencyGraph` at startup
127
- - 100% test coverage enforced via SimpleCov (95% minimum threshold)
128
- - Quality task in `.loki` runs flog after tests
129
-
130
- [Unreleased]: https://github.com/MadBomber/asgard/compare/v0.2.0...HEAD
131
- [0.2.0]: https://github.com/MadBomber/asgard/compare/v0.1.2...v0.2.0
132
- [0.1.2]: https://github.com/MadBomber/asgard/compare/v0.1.1...v0.1.2
133
- [0.1.1]: https://github.com/MadBomber/asgard/compare/v0.1.0...v0.1.1
134
- [0.1.0]: https://github.com/MadBomber/asgard/releases/tag/v0.1.0
1
+ --8<-- "CHANGELOG.md"
data/docs/index.md CHANGED
@@ -18,6 +18,7 @@
18
18
  <li><strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code></li>
19
19
  <li><strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories</li>
20
20
  <li><strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files loaded via <code>import</code></li>
21
+ <li><strong>Scheduled Tasks</strong> — run any task periodically under launchd (macOS) or systemd timers (Linux) with <code>schedule</code> and <code>asgard schedule install</code></li>
21
22
  <li><strong>Built-in Flags</strong> — <code>--version</code>, <code>--debug</code>, <code>--verbose</code>, and <code>--doctor</code> available on every task</li>
22
23
  </ul>
23
24
  </td>
data/docs/schedule.md ADDED
@@ -0,0 +1,101 @@
1
+ # Scheduled Tasks
2
+
3
+ Asgard can run any task on a schedule. You declare schedules in the project's own `.loki`, and `asgard schedule install` hands them to the platform's own scheduler: **launchd** on macOS, **systemd user timers** on Linux. Both schedulers run a calendar job that was missed while the machine slept as soon as it wakes.
4
+
5
+ Scheduling is built into the gem, so there is nothing to import or require.
6
+
7
+ ---
8
+
9
+ ## Declaring Schedules
10
+
11
+ Call `schedule` at class level inside `Tasks`:
12
+
13
+ ```ruby
14
+ # .loki
15
+ class Tasks
16
+ desc "daily_summary", "Summarize the day's work"
17
+ def daily_summary = sh "bin/summary"
18
+
19
+ schedule :daily_summary, at: "17:30", on: :weekdays
20
+ schedule :weekly_report, at: "16:00", on: :friday
21
+ schedule :sync, every: 3600 # seconds
22
+ schedule :backup, at: %w[02:00 14:00] # on: defaults to :daily
23
+
24
+ # The task's own options go in options:, split the way a shell would
25
+ # (quotes respected).
26
+ schedule :report, options: "--format md -v", at: "08:00", on: :weekdays
27
+ schedule :report, options: "--period week", at: "16:00", on: :friday
28
+ schedule :notify, options: "--msg 'backup done'", every: 86_400, as: "notify_daily"
29
+ end
30
+ ```
31
+
32
+ A declaration needs exactly one of `at:` or `every:`.
33
+
34
+ | Keyword | Meaning |
35
+ |---------|---------|
36
+ | `at:` | `"HH:MM"` (24-hour), or an Array of times |
37
+ | `on:` | `:daily` (default), `:weekdays`, `:weekends`, a day (`:friday`), or an Array of days |
38
+ | `every:` | Interval in seconds, or anything that responds to `in_seconds` (an ActiveSupport `Duration`) |
39
+ | `options:` | The task's own arguments, as a String (split shell-style) or an Array of words |
40
+ | `env:` | `{ "KEY" => "value" }`: literal environment variables added to the job |
41
+ | `as:` | The entry's name (letters, digits, `_ . -`) |
42
+
43
+ ### Entry Names
44
+
45
+ Every entry has a name, and the subcommands below take it as `NAME`. The name defaults to the task name. When the entry has `options:`, the default is a slug of the task plus its options (`:report, options: "--format md -v"` becomes `report-format-md-v`), so one task can be scheduled several times with different flags. `as:` overrides the default. Declaring two *different* entries with the same name raises an error.
46
+
47
+ ### Durations
48
+
49
+ Asgard doesn't depend on ActiveSupport. If you want `every: 3.minutes`, require it yourself at the top of your `.loki`:
50
+
51
+ ```ruby
52
+ require "active_support/core_ext/integer/time"
53
+
54
+ class Tasks
55
+ schedule :sync, every: 15.minutes
56
+ end
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Managing Schedules
62
+
63
+ `schedule` is also a command with subcommands. They work the same way on both platforms:
64
+
65
+ ```bash
66
+ asgard schedule preview # print the job files install would write
67
+ asgard schedule install # load declared entries; drop entries no longer declared
68
+ asgard schedule list # installed entries, schedule, state, last exit status
69
+ asgard schedule stop NAME # stop one entry (stays stopped across reboots and installs)
70
+ asgard schedule start NAME # start a stopped entry, or install just this one
71
+ asgard schedule trigger NAME # run an installed entry now, under the scheduler
72
+ asgard schedule log NAME [-f] # print the entry's log (-f keeps following it)
73
+ asgard schedule remove # unload and delete all of this project's entries
74
+ ```
75
+
76
+ Re-run `asgard schedule install` after changing declarations or your `PATH`.
77
+
78
+ ---
79
+
80
+ ## How Jobs Run
81
+
82
+ Each entry runs `asgard <task> [options]` from the directory that holds `.loki`, so installing from any subdirectory gives the same result. The job gets the `PATH` that was current when you ran `install`, plus any `env:` variables.
83
+
84
+ If the project has a `.envrc`, the job runs under `direnv exec`, so API keys and other secrets load from `.envrc` at run time and are never copied into the job files. If `direnv` isn't on your `PATH`, `install` warns that `.envrc` will not be loaded.
85
+
86
+ Entries are scoped to the project (the name of the directory holding `.loki`), so `list`, `install` and `remove` only touch this project's jobs.
87
+
88
+ | | macOS (launchd) | Linux (systemd) |
89
+ |---|---|---|
90
+ | Job files | `~/Library/LaunchAgents/com.madbomber.asgard.<project>.<name>.plist` | `~/.config/systemd/user/asgard.<project>.<name>.{service,timer}` |
91
+ | Logs | `~/Library/Logs/asgard/` | `~/.local/state/asgard/` (honors `XDG_STATE_HOME`) |
92
+ | Stop | `launchctl disable` | `systemctl --user disable --now` |
93
+ | Caveats | runs only while you're logged in | runs only while you're logged in unless `loginctl enable-linger`; needs systemd 240+ |
94
+
95
+ Other platforms aren't supported. The `schedule` subcommands there exit with an error.
96
+
97
+ ---
98
+
99
+ ## Name Collisions
100
+
101
+ The built-in command is registered as `_schedule` and mapped to `schedule`, following Asgard's `_` convention for gem-owned tasks. If your `.loki` defines its own `schedule` task, `asgard schedule` still dispatches to the built-in command.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Required first: Asgard::Schedule::Error subclasses Asgard::Error.
4
+ module Asgard
5
+ class Error < StandardError; end
6
+ class CircularDependencyError < Error; end
7
+ end