asgard 0.5.1 → 0.6.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 +4 -4
- data/CHANGELOG.md +179 -99
- data/README.md +141 -46
- data/bin/asgard +10 -0
- data/lib/asgard/base/dispatch.rb +257 -31
- data/lib/asgard/base/registry.rb +7 -3
- data/lib/asgard/base.rb +140 -9
- data/lib/asgard/declarations.rb +21 -0
- data/lib/asgard/doctor/task_sections.rb +11 -3
- data/lib/asgard/doctor.rb +12 -8
- data/lib/asgard/jobs/commands.rb +152 -0
- data/lib/asgard/jobs/declaration.rb +149 -0
- data/lib/asgard/jobs/launcher.rb +109 -0
- data/lib/asgard/jobs/state.rb +190 -0
- data/lib/asgard/jobs/stopper.rb +141 -0
- data/lib/asgard/jobs.rb +25 -0
- data/lib/asgard/kernel_methods.rb +19 -14
- data/lib/asgard/leading_flag.rb +10 -0
- data/lib/asgard/pid.rb +130 -0
- data/lib/asgard/schedule/commands.rb +39 -11
- data/lib/asgard/schedule/declaration.rb +139 -12
- data/lib/asgard/schedule/job_files.rb +24 -0
- data/lib/asgard/schedule/launchd.rb +55 -18
- data/lib/asgard/schedule/systemd.rb +69 -27
- data/lib/asgard/schedule/table.rb +34 -16
- data/lib/asgard/schedule.rb +49 -9
- data/lib/asgard/shell.rb +372 -44
- data/lib/asgard/state_dir.rb +84 -0
- data/lib/asgard/tasks.rb +8 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +26 -1
- data/lib/asgard/watch/commands.rb +52 -12
- data/lib/asgard/watch/daemon.rb +334 -43
- data/lib/asgard/watch/declaration.rb +135 -40
- data/lib/asgard/watch/dispatcher.rb +61 -10
- data/lib/asgard/watch/mailbox.rb +50 -0
- data/lib/asgard/watch/reporter.rb +50 -34
- data/lib/asgard/watch/spawn.rb +41 -16
- data/lib/asgard/watch/state.rb +93 -27
- data/lib/asgard/watch/worker.rb +44 -9
- data/lib/asgard/watch.rb +4 -11
- data/lib/asgard.rb +80 -14
- metadata +167 -186
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.loki +0 -39
- data/.reek.yml +0 -166
- data/.rubocop.yml +0 -163
- data/Archspec.rb +0 -24
- data/CLAUDE.md +0 -139
- data/COMMITS.md +0 -196
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/doc_tasks.loki +0 -21
- data/docs/api.md +0 -368
- data/docs/assets/css/custom.css +0 -93
- data/docs/assets/images/asgard.jpg +0 -0
- data/docs/changelog.md +0 -1
- data/docs/dependencies.md +0 -313
- data/docs/environment.md +0 -129
- data/docs/examples.md +0 -194
- data/docs/getting-started.md +0 -181
- data/docs/helpers.md +0 -262
- data/docs/index.md +0 -87
- data/docs/options.md +0 -224
- data/docs/schedule.md +0 -106
- data/docs/shell.md +0 -237
- data/docs/subcommands.md +0 -181
- data/docs/task-files.md +0 -407
- data/docs/tasks.md +0 -326
- data/docs/variables.md +0 -338
- data/docs/watch.md +0 -123
- data/examples/.env +0 -4
- data/examples/.envrc +0 -3
- data/examples/.loki +0 -24
- data/examples/bad.loki +0 -63
- data/examples/concurrent.loki +0 -58
- data/examples/confirm.loki +0 -48
- data/examples/db_subcommands.loki +0 -74
- data/examples/depends_on_block/bad/.loki +0 -51
- data/examples/depends_on_block/good/.loki +0 -83
- data/examples/do.loki +0 -45
- data/examples/env_usage.loki +0 -27
- data/examples/kitchen_sink.loki +0 -210
- data/examples/server_subcommands.loki +0 -65
- data/examples/subdir/.loki +0 -12
- data/examples/subdir/import_demo.loki +0 -14
- data/examples/subdir/import_up_demo.loki +0 -18
- data/examples/watcher_examples/.envrc +0 -3
- data/examples/watcher_examples/.loki +0 -54
- data/examples/watcher_examples/README.md +0 -112
- data/examples/watcher_examples/bin/hello +0 -3
- data/examples/watcher_examples/data/records.json +0 -1
- data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
- data/examples/watcher_examples/docs/index.md +0 -4
- data/examples/watcher_examples/lib/greeter.rb +0 -7
- data/gem_tasks.loki +0 -51
- data/git.loki +0 -13
- data/mkdocs.yml +0 -166
- data/quality.loki +0 -266
- data/quality_rails.loki +0 -47
- data/xyzzy.loki +0 -12
data/docs/watch.md
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# Watched Files
|
|
2
|
-
|
|
3
|
-
Asgard can run a task whenever files change. You declare what each task watches in the project's own `.loki`, and `asgard watcher start` runs a background process that listens for changes and runs the matching tasks, handing each one the list of changed files in a known, reproducible environment.
|
|
4
|
-
|
|
5
|
-
Watching is built into the gem, so there is nothing to import or require. File events come from the [listen](https://github.com/guard/listen) gem: native adapters on macOS and Linux, polling elsewhere.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Declaring Watches
|
|
10
|
-
|
|
11
|
-
Call `watch` at class level inside `Tasks`:
|
|
12
|
-
|
|
13
|
-
```ruby
|
|
14
|
-
# .loki
|
|
15
|
-
class Tasks
|
|
16
|
-
desc "lint", "Lint whatever changed"
|
|
17
|
-
def lint
|
|
18
|
-
files = changes.modified + changes.added
|
|
19
|
-
sh "rubocop #{files.shelljoin}" unless files.empty?
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
watch :lint, paths: "lib/**/*.rb"
|
|
23
|
-
watch :test, paths: %w[lib test], only: /\.rb\z/, ignore: %r{\Afixtures/}
|
|
24
|
-
watch :docs, paths: "docs", env: { BUILD_ENV: "dev" }
|
|
25
|
-
watch :reindex, paths: "data/**/*.json", events: %i[added removed]
|
|
26
|
-
watch :lint, paths: "bin/**/*", as: "lint-bin"
|
|
27
|
-
end
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
| Keyword | Meaning |
|
|
31
|
-
|---------|---------|
|
|
32
|
-
| `paths:` | A directory, a file, or a glob relative to the project root, or an Array of them |
|
|
33
|
-
| `only:` | Regexp or Array of Regexps a changed path must match (relative to the watched directory) |
|
|
34
|
-
| `ignore:` | Regexp or Array of Regexps that silence a path, added to the defaults below |
|
|
35
|
-
| `events:` | Which kinds of change count: `:changed`, `:added`, `:removed`, or `:all` (default); `:modified` means `:changed` |
|
|
36
|
-
| `debounce:` | Seconds to wait after the last event before the task runs (default 0.5) |
|
|
37
|
-
| `env:` | `{ "KEY" => "value" }`: environment variables added to the task |
|
|
38
|
-
| `as:` | The entry's name (letters, digits, `_ . -`) |
|
|
39
|
-
|
|
40
|
-
### Entry Names
|
|
41
|
-
|
|
42
|
-
Every entry has a name, shown by `list` and in the log. It defaults to the task name. To watch one task from two declarations, give the second an `as:` name; declaring two *different* entries with the same name raises an error.
|
|
43
|
-
|
|
44
|
-
### Paths, Patterns and Filters
|
|
45
|
-
|
|
46
|
-
A glob such as `lib/**/*.rb` watches `lib` and keeps the paths that match the pattern, the way a shell would: `**` spans directories, `*` and `?` stay within one, `{a,b}` is an alternative. A plain file watches its directory and keeps just that name, so a file that does not exist yet can still be watched. A bare directory takes everything beneath it.
|
|
47
|
-
|
|
48
|
-
`only:` combines with the patterns: a path must match one of the patterns *and* one of the `only:` expressions. Several patterns in one declaration are alternatives, and because the filter applies to every directory the declaration watches, `%w[lib/**/*.rb docs/**/*.md]` also accepts a `.md` file under `lib`.
|
|
49
|
-
|
|
50
|
-
A bare directory cannot share a declaration with a pattern or a file, because the pattern would silently filter the directory too. Write the directory as `dir/**/*`, or declare it separately.
|
|
51
|
-
|
|
52
|
-
### Default Ignores
|
|
53
|
-
|
|
54
|
-
Every watch ignores `.git/`, `coverage/`, `pkg/`, `tmp/`, `log/` and `node_modules/` at any depth, plus listen's own defaults, so a task that writes its output inside the project never retriggers itself.
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## Managing the Watcher
|
|
59
|
-
|
|
60
|
-
`watcher` is also a command with subcommands:
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
asgard watcher start # start the watcher for this project in the background
|
|
64
|
-
asgard watcher stop # stop it; a task already running finishes first
|
|
65
|
-
asgard watcher list # this project's watches, daemon state, each entry's last run (--all: every project's)
|
|
66
|
-
asgard watcher preview # the command and environment each watch would run
|
|
67
|
-
asgard watcher log [-f] # print (or follow) the watcher's log
|
|
68
|
-
asgard watcher run # watch in the foreground (what start runs detached)
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
The watcher watches its own `.loki` files too. Save one, and the running watcher restarts itself with the new declarations.
|
|
72
|
-
|
|
73
|
-
`stop` never signals a running task. It stops listening, drops triggers that have not started, waits for in-flight tasks to exit (naming them while it waits), then exits. A task that hangs keeps `stop` waiting; kill the task and `stop` completes.
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## How Tasks Run
|
|
78
|
-
|
|
79
|
-
A trigger runs `asgard <task>` from the project root as a separate process, using the very `asgard` executable that started the watcher, by absolute path, so a different version installed elsewhere on `PATH` is never picked up. Different tasks run concurrently. The same task never overlaps itself: a trigger that arrives while its task is still running waits, and the log says `queued behind pid N`. Save three times during a long test run and three more runs follow, one at a time.
|
|
80
|
-
|
|
81
|
-
Two declarations for the same task that both match a file run that task twice, back to back. A task's own `depends_on` runs inside its process as usual; the watcher does not collapse dependencies across entries.
|
|
82
|
-
|
|
83
|
-
### The Environment
|
|
84
|
-
|
|
85
|
-
The task sees the same environment whether the watcher was started from an interactive shell or from a service. Nothing else leaks in from the daemon.
|
|
86
|
-
|
|
87
|
-
| Layer | Contents |
|
|
88
|
-
|---|---|
|
|
89
|
-
| Base | `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_*`, `TMPDIR`, `SSH_AUTH_SOCK`, captured when the watcher starts |
|
|
90
|
-
| Declared | The entry's `env:`; a project `.envrc` through `direnv exec` when direnv is on `PATH`; a `.env` through your `.loki`'s own `dotenv` call |
|
|
91
|
-
| Trigger | `ASGARD_WATCH` (the entry name), `ASGARD_WATCH_ROOT`, and the changed paths in `ASGARD_MODIFIED`, `ASGARD_ADDED` and `ASGARD_REMOVED`, one path per line, relative to the root |
|
|
92
|
-
|
|
93
|
-
### The `changes` Accessor
|
|
94
|
-
|
|
95
|
-
Inside a task, `changes` returns what triggered it:
|
|
96
|
-
|
|
97
|
-
```ruby
|
|
98
|
-
changes.modified # => ["lib/asgard/base.rb"]
|
|
99
|
-
changes.added # => []
|
|
100
|
-
changes.removed # => []
|
|
101
|
-
changes.all # => every path once, in that order
|
|
102
|
-
changes.empty? # => true when the task was run by hand
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Every list is empty when the task is run from the command line, so one task body serves both.
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## Files and Logs
|
|
110
|
-
|
|
111
|
-
The watcher keeps its state outside the repository under `~/.local/state/asgard/watch/<project>/` (honors `XDG_STATE_HOME`): the pidfile, `watcher.log` with one line per trigger, spawn and exit plus every task's output, the declarations recorded at `start`, and one JSON record per entry with its last run. `asgard watcher list --all` reads those records for every project without loading any `.loki`.
|
|
112
|
-
|
|
113
|
-
Running the watcher as a launchd or systemd service is not yet built in; `asgard watcher run` is the foreground process a service would keep alive.
|
|
114
|
-
|
|
115
|
-
---
|
|
116
|
-
|
|
117
|
-
## Requirements
|
|
118
|
-
|
|
119
|
-
Watching needs Ruby 3.2 or later. The gem depends on `listen` and on `ractor_queue`, a native extension, so `gem install asgard` compiles it and needs a C++17 compiler on the machine.
|
|
120
|
-
|
|
121
|
-
## Name Collisions
|
|
122
|
-
|
|
123
|
-
The built-in command is registered as `_watcher` and mapped to `watcher`, following Asgard's `_` convention for gem-owned tasks. If your `.loki` defines its own `watcher` task, `asgard watcher` still dispatches to the built-in command.
|
data/examples/.env
DELETED
data/examples/.envrc
DELETED
data/examples/.loki
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# examples/.loki — root task file for the examples directory.
|
|
2
|
-
#
|
|
3
|
-
# Asgard loads only this file by default. Everything else must be
|
|
4
|
-
# explicitly pulled in with import.
|
|
5
|
-
#
|
|
6
|
-
# import(path)
|
|
7
|
-
# Load a specific file or a glob of files, idempotently. Absolute
|
|
8
|
-
# paths, relative paths, and globs are all accepted.
|
|
9
|
-
#
|
|
10
|
-
# import_up(name)
|
|
11
|
-
# Walk CWD and every ancestor directory looking for `name`, then
|
|
12
|
-
# import it. Useful inside a nested task file that needs to pull in
|
|
13
|
-
# a shared config from the project root without knowing how deep it is.
|
|
14
|
-
#
|
|
15
|
-
# loki_up(name)
|
|
16
|
-
# Same walk as import_up but just returns the absolute path (or nil)
|
|
17
|
-
# without loading it — handy when you need the path for another purpose.
|
|
18
|
-
|
|
19
|
-
# Load all *.loki files in this directory (kitchen_sink, concurrent, etc.)
|
|
20
|
-
import "*.loki"
|
|
21
|
-
|
|
22
|
-
# Load a task file from a subdirectory.
|
|
23
|
-
# import also accepts a direct path, not just a glob.
|
|
24
|
-
import "subdir/import_demo.loki"
|
data/examples/bad.loki
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# Demonstrates review.md Critical Issue #1: parallel depends_on tasks that
|
|
3
|
-
# write to shared instance state on `self` race each other.
|
|
4
|
-
#
|
|
5
|
-
# quality.loki's *_check tasks each write a *different* named ivar
|
|
6
|
-
# (@test_result, @rubocop_result, ...), so MRI's GVL happens to hide the
|
|
7
|
-
# race in practice — every write lands on a distinct slot. This file
|
|
8
|
-
# isolates the actual hazard by having 4 parallel workers read-modify-write
|
|
9
|
-
# the SAME @hits counter, the way any real *_check task would if it
|
|
10
|
-
# accumulated into one shared results structure instead of one ivar each.
|
|
11
|
-
#
|
|
12
|
-
# `@hits = @hits + 1` is not one atomic operation — it's a read, an add,
|
|
13
|
-
# then a write — and MRI can switch threads between those steps. Thread.pass
|
|
14
|
-
# inside the loop forces exactly those switches, so the lost-update race
|
|
15
|
-
# shows up reliably instead of "most of the time."
|
|
16
|
-
#
|
|
17
|
-
# Run with:
|
|
18
|
-
# asgard bad_race
|
|
19
|
-
#
|
|
20
|
-
# Expected @hits: 4000 (4 workers x 1000 increments each)
|
|
21
|
-
# Actual: consistently lower — proof of the lost-update race described in
|
|
22
|
-
# review.md item #1.
|
|
23
|
-
|
|
24
|
-
BAD_RACE_WORKERS = 4
|
|
25
|
-
BAD_RACE_REPS = 1000
|
|
26
|
-
|
|
27
|
-
class Tasks
|
|
28
|
-
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
29
|
-
def racer_1 = bump_shared_counter
|
|
30
|
-
|
|
31
|
-
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
32
|
-
def racer_2 = bump_shared_counter
|
|
33
|
-
|
|
34
|
-
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
35
|
-
def racer_3 = bump_shared_counter
|
|
36
|
-
|
|
37
|
-
desc "Increment the shared @hits counter, unsynchronized (worker)"
|
|
38
|
-
def racer_4 = bump_shared_counter
|
|
39
|
-
|
|
40
|
-
depends_on [:racer_1, :racer_2, :racer_3, :racer_4]
|
|
41
|
-
desc "Show the lost-update race in @hits"
|
|
42
|
-
def bad_race
|
|
43
|
-
expected = BAD_RACE_WORKERS * BAD_RACE_REPS
|
|
44
|
-
puts "expected @hits == #{expected}, got #{@hits}"
|
|
45
|
-
|
|
46
|
-
if @hits == expected
|
|
47
|
-
puts "no corruption this run — the race is timing-dependent, rerun a few times"
|
|
48
|
-
else
|
|
49
|
-
puts "DATA RACE CONFIRMED: #{expected - @hits} update(s) lost"
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
no_commands do
|
|
54
|
-
def bump_shared_counter
|
|
55
|
-
@hits ||= 0
|
|
56
|
-
BAD_RACE_REPS.times do
|
|
57
|
-
current = @hits # read
|
|
58
|
-
Thread.pass # force a context switch before the write lands
|
|
59
|
-
@hits = current + 1 # write — another thread's read may already be stale
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
data/examples/concurrent.loki
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# Demonstrates concurrent task execution via parallel depends_on groups.
|
|
3
|
-
#
|
|
4
|
-
# Each worker prints its letter repeatedly with random delays. Because
|
|
5
|
-
# worker_a, worker_b, and worker_c run in separate threads their output
|
|
6
|
-
# interleaves on stdout, proving real concurrency.
|
|
7
|
-
#
|
|
8
|
-
# Execution order:
|
|
9
|
-
# start → worker_a + worker_b + worker_c (all three concurrent) → finish
|
|
10
|
-
#
|
|
11
|
-
# Run with:
|
|
12
|
-
# asgard finish
|
|
13
|
-
#
|
|
14
|
-
# Sample output (character order varies every run):
|
|
15
|
-
# start
|
|
16
|
-
# ABCBACBACBABCBACBACB
|
|
17
|
-
# end
|
|
18
|
-
|
|
19
|
-
$stdout.sync = true # flush every print immediately across all threads
|
|
20
|
-
|
|
21
|
-
CONCURRENT_REPS = 10 # how many times each worker prints its character
|
|
22
|
-
|
|
23
|
-
class Tasks
|
|
24
|
-
desc "Print start marker"
|
|
25
|
-
def start
|
|
26
|
-
puts "starting demo of concurrent task execution ..."
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
desc "Print 'A' repeatedly with random delays"
|
|
30
|
-
def worker_a
|
|
31
|
-
CONCURRENT_REPS.times do
|
|
32
|
-
print "A"
|
|
33
|
-
sleep rand(0.05..0.3)
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
desc "Print 'B' repeatedly with random delays"
|
|
38
|
-
def worker_b
|
|
39
|
-
CONCURRENT_REPS.times do
|
|
40
|
-
print "B"
|
|
41
|
-
sleep rand(0.05..0.3)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
desc "Print 'C' repeatedly with random delays"
|
|
46
|
-
def worker_c
|
|
47
|
-
CONCURRENT_REPS.times do
|
|
48
|
-
print "C"
|
|
49
|
-
sleep rand(0.05..0.3)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
depends_on :start, [:worker_a, :worker_b, :worker_c]
|
|
54
|
-
desc "Print end marker after all workers complete"
|
|
55
|
-
def finish
|
|
56
|
-
puts "\nfini - the end of concurrent task demo"
|
|
57
|
-
end
|
|
58
|
-
end
|
data/examples/confirm.loki
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# Demonstrates a guard task: list `confirm` first in a dangerous task's
|
|
3
|
-
# depends_on and the task only runs after the user answers yes.
|
|
4
|
-
#
|
|
5
|
-
# Run with:
|
|
6
|
-
# asgard reset_database # asks, runs only on y/yes
|
|
7
|
-
# ASGARD_YES=1 asgard reset_database # no question (scripts, CI)
|
|
8
|
-
#
|
|
9
|
-
# Behavior:
|
|
10
|
-
# - A dependency doesn't know which task asked for it, so the question
|
|
11
|
-
# shows the whole command line: "Really run `asgard reset_database`?"
|
|
12
|
-
# - Answering no raises Asgard::Error, which stops everything that
|
|
13
|
-
# hasn't run yet; asgard prints "asgard: aborted ..." and exits 1.
|
|
14
|
-
# Returning :fail would NOT work: a dependency's return value never
|
|
15
|
-
# stops the task that depends on it.
|
|
16
|
-
# - With no terminal to ask (a schedule, the watcher, a pipe) it refuses
|
|
17
|
-
# unless ASGARD_YES is set, rather than hanging or guessing.
|
|
18
|
-
# - It runs at most once per asgard invocation, so
|
|
19
|
-
# `asgard do reset_database, drop_cache` asks once for the whole line.
|
|
20
|
-
# - Put confirm in its own first stage. In a parallel group,
|
|
21
|
-
# `depends_on [:confirm, :backup]`, backup runs while the question waits.
|
|
22
|
-
|
|
23
|
-
class Tasks
|
|
24
|
-
# Not a Thor command (a class method), so it can be tested on its own:
|
|
25
|
-
# Tasks.confirmed?("y", yes_env: nil) #=> true
|
|
26
|
-
def self.confirmed?(answer, yes_env:)
|
|
27
|
-
return true if yes_env && !yes_env.empty?
|
|
28
|
-
|
|
29
|
-
%w[y yes].include?(answer.to_s.strip.downcase)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
desc "Ask before running the task that depends on this", hide: true
|
|
33
|
-
def confirm
|
|
34
|
-
command = "asgard #{ARGV.join(' ')}"
|
|
35
|
-
yes_env = env(:asgard_yes, "")
|
|
36
|
-
return if self.class.confirmed?(nil, yes_env:)
|
|
37
|
-
raise Asgard::Error, "refusing `#{command}`: no terminal to confirm on (set ASGARD_YES=1)" unless $stdin.tty?
|
|
38
|
-
|
|
39
|
-
answer = ask("Really run `#{command}`? [y/N]")
|
|
40
|
-
raise Asgard::Error, "aborted `#{command}`" unless self.class.confirmed?(answer, yes_env:)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
depends_on :confirm
|
|
44
|
-
desc "Pretend to drop and recreate the database (demo for confirm)"
|
|
45
|
-
def reset_database
|
|
46
|
-
puts "Database reset."
|
|
47
|
-
end
|
|
48
|
-
end
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# Demonstrates Thor subcommands with Asgard's depends_on chaining within
|
|
3
|
-
# the subcommand group. Inherits from Tasks for full Asgard DSL access.
|
|
4
|
-
#
|
|
5
|
-
# Usage:
|
|
6
|
-
# asgard db # shows subcommand help
|
|
7
|
-
# asgard db migrate
|
|
8
|
-
# asgard db migrate 20240101120000 --dry-run
|
|
9
|
-
# asgard db rollback
|
|
10
|
-
# asgard db rollback 3
|
|
11
|
-
# asgard db seed --file db/seeds/staging.rb
|
|
12
|
-
# asgard db reset # runs: rollback → migrate → seed
|
|
13
|
-
# asgard db console --env staging
|
|
14
|
-
# asgard db status
|
|
15
|
-
|
|
16
|
-
class DBCommands < Tasks
|
|
17
|
-
desc "migrate [VERSION]", "Run pending migrations up to VERSION"
|
|
18
|
-
option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "Print SQL without executing"
|
|
19
|
-
option :verbose, aliases: "-v", type: :boolean, default: false, desc: "Show each migration as it runs"
|
|
20
|
-
def migrate(version = nil)
|
|
21
|
-
target = version ? "to version #{version}" : "to latest"
|
|
22
|
-
puts "Migrating #{target}#{options[:dry_run] ? " (dry run)" : ""}..."
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
desc "rollback [STEPS]", "Roll back the last STEPS migrations (default: 1)"
|
|
26
|
-
option :dry_run, aliases: "-n", type: :boolean, default: false, desc: "Print SQL without executing"
|
|
27
|
-
def rollback(steps = "1")
|
|
28
|
-
puts "Rolling back #{steps} migration(s)#{options[:dry_run] ? " (dry run)" : ""}..."
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
desc "seed [FILE]", "Load seed data into the database"
|
|
32
|
-
option :env, type: :string, default: "development",
|
|
33
|
-
enum: %w[development staging production],
|
|
34
|
-
desc: "Environment to seed"
|
|
35
|
-
def seed(file = "db/seeds.rb")
|
|
36
|
-
puts "Seeding #{options[:env]} from #{file}..."
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# depends_on chains within the subcommand group:
|
|
40
|
-
# rollback → migrate → seed → reset
|
|
41
|
-
depends_on :rollback, :migrate, :seed
|
|
42
|
-
desc "Rollback all migrations, re-migrate, and reseed"
|
|
43
|
-
def reset
|
|
44
|
-
puts "Database reset complete."
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
long_desc <<~DESC
|
|
48
|
-
Opens an interactive SQL console connected to the configured database.
|
|
49
|
-
|
|
50
|
-
The console inherits credentials from the current environment's
|
|
51
|
-
database.yml (Rails) or DATABASE_URL.
|
|
52
|
-
|
|
53
|
-
Examples:\x5
|
|
54
|
-
asgard db console\x5
|
|
55
|
-
asgard db console --env staging
|
|
56
|
-
DESC
|
|
57
|
-
desc "Open an interactive database console"
|
|
58
|
-
option :env, type: :string, default: "development",
|
|
59
|
-
enum: %w[development staging production],
|
|
60
|
-
desc: "Environment to connect to"
|
|
61
|
-
def console
|
|
62
|
-
puts "Opening #{options[:env]} database console..."
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
desc "Show applied and pending migrations"
|
|
66
|
-
def status
|
|
67
|
-
puts "Checking migration status..."
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
class Tasks
|
|
72
|
-
desc "db SUBCOMMAND", "Manage the database"
|
|
73
|
-
subcommand "db", DBCommands
|
|
74
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# examples/depends_on_block/bad/.loki
|
|
2
|
-
# BAD example: depends_on block returning a mis-shaped result.
|
|
3
|
-
#
|
|
4
|
-
# Compare with ../good/.loki, which does this correctly:
|
|
5
|
-
#
|
|
6
|
-
# depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
|
|
7
|
-
# ^ ^
|
|
8
|
-
# one wrapping [ ] — this is "one stage, containing every matching
|
|
9
|
-
# task, all running in parallel."
|
|
10
|
-
#
|
|
11
|
-
# It's an easy typo to add a SECOND wrapping array, thinking the outer
|
|
12
|
-
# array is "the list of stages" and the inner one is "this stage's tasks"
|
|
13
|
-
# as two separate concerns:
|
|
14
|
-
#
|
|
15
|
-
# depends_on { [[all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)]] }
|
|
16
|
-
# ^ ^
|
|
17
|
-
# that's one stage too many — the middle array ends up
|
|
18
|
-
# *inside* a stage as a dependency, not as the stage itself.
|
|
19
|
-
#
|
|
20
|
-
# depends_on's block/Proc result is validated the moment it resolves
|
|
21
|
-
# (during validate_deps!, before any task runs): every stage must be a
|
|
22
|
-
# Symbol/String (sequential) or an Array of Symbol/String (parallel group)
|
|
23
|
-
# — nesting one level deeper than that is rejected with Asgard::Error
|
|
24
|
-
# instead of failing later with a confusing NoMethodError.
|
|
25
|
-
#
|
|
26
|
-
# Run with (from this directory):
|
|
27
|
-
# asgard quality
|
|
28
|
-
#
|
|
29
|
-
# Expected output (validate_deps! aborts before quality — or any other
|
|
30
|
-
# task — ever runs):
|
|
31
|
-
# asgard: depends_on proc/block for 'quality' returned invalid dependency
|
|
32
|
-
# [:a_check, :b_check, :c_check] (Array) in a parallel group; expected a
|
|
33
|
-
# Symbol or String
|
|
34
|
-
|
|
35
|
-
class Tasks
|
|
36
|
-
desc "Run the first check"
|
|
37
|
-
def a_check = puts "running a_check ..."
|
|
38
|
-
|
|
39
|
-
desc "Run the second check"
|
|
40
|
-
def b_check = puts "running b_check ..."
|
|
41
|
-
|
|
42
|
-
desc "Run the third check"
|
|
43
|
-
def c_check = puts "running c_check ..."
|
|
44
|
-
|
|
45
|
-
# BUG: double-wrapped — [[ ... ]] instead of [ ... ]. See comment above.
|
|
46
|
-
depends_on { [[all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)]] }
|
|
47
|
-
desc "Run every *_check task in parallel, then report"
|
|
48
|
-
def quality
|
|
49
|
-
puts "quality: all *_check tasks passed"
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# examples/depends_on_block/good/.loki
|
|
2
|
-
# GOOD example: depends_on with a block instead of a Proc/lambda.
|
|
3
|
-
#
|
|
4
|
-
# A block behaves exactly like `-> { ... }` — it defers resolution to
|
|
5
|
-
# validate_deps! (once, after every .loki file has loaded) instead of
|
|
6
|
-
# resolving the moment depends_on is evaluated. This is the real pattern
|
|
7
|
-
# from this gem's own quality.loki: gather every task whose name ends in
|
|
8
|
-
# `_check`, without having to know their names or load order upfront.
|
|
9
|
-
#
|
|
10
|
-
# The block's return value is validated once it resolves: it must be an
|
|
11
|
-
# Array of stages, each a Symbol/String (sequential) or an Array of
|
|
12
|
-
# Symbol/String (parallel group) — see ../bad/.loki for what happens when
|
|
13
|
-
# that shape is wrong.
|
|
14
|
-
#
|
|
15
|
-
# `{ ... }` braces are used below for the short, single-line blocks (quality,
|
|
16
|
-
# full_cycle) — standard Ruby style reserves `do ... end` for a block that
|
|
17
|
-
# spans multiple statements, shown in smoke further down.
|
|
18
|
-
#
|
|
19
|
-
# Run with (from this directory):
|
|
20
|
-
# asgard quality
|
|
21
|
-
#
|
|
22
|
-
# Expected output:
|
|
23
|
-
# running a_check ...
|
|
24
|
-
# running b_check ...
|
|
25
|
-
# running c_check ...
|
|
26
|
-
# quality: all *_check tasks passed
|
|
27
|
-
|
|
28
|
-
class Tasks
|
|
29
|
-
desc "Run the first check"
|
|
30
|
-
def a_check = puts "running a_check ..."
|
|
31
|
-
|
|
32
|
-
desc "Run the second check"
|
|
33
|
-
def b_check = puts "running b_check ..."
|
|
34
|
-
|
|
35
|
-
desc "Run the third check"
|
|
36
|
-
def c_check = puts "running c_check ..."
|
|
37
|
-
|
|
38
|
-
# self inside the block is the Tasks class (it's written directly in the
|
|
39
|
-
# class body), so all_commands can be called bare, with no self.class.
|
|
40
|
-
# prefix — even though the block itself isn't invoked until later.
|
|
41
|
-
depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
|
|
42
|
-
desc "Run every *_check task in parallel, then report"
|
|
43
|
-
def quality
|
|
44
|
-
puts "quality: all *_check tasks passed"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# A block can also return a mix of sequential Symbols and parallel Arrays,
|
|
48
|
-
# exactly like the plain splat form: depends_on :setup, [:a_check, :b_check], :teardown
|
|
49
|
-
desc "Prepare fixtures before the checks run"
|
|
50
|
-
def setup = puts "setup: fixtures ready"
|
|
51
|
-
|
|
52
|
-
desc "Clean up fixtures after the checks run"
|
|
53
|
-
def teardown = puts "teardown: fixtures removed"
|
|
54
|
-
|
|
55
|
-
depends_on { [:setup, %i[a_check b_check], :teardown] }
|
|
56
|
-
desc "setup, then a_check+b_check in parallel, then teardown"
|
|
57
|
-
def full_cycle
|
|
58
|
-
puts "full_cycle: done"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# A block spanning multiple statements — local variables, some computation
|
|
62
|
-
# — reads more naturally as do...end than as one long `{ ... }` line.
|
|
63
|
-
# Whatever it does along the way, the last expression is still what gets
|
|
64
|
-
# returned and validated: here, [:setup, Array, :teardown], the same shape
|
|
65
|
-
# as full_cycle above.
|
|
66
|
-
#
|
|
67
|
-
# Gotcha: this block runs during validate_deps!, which happens *before*
|
|
68
|
-
# Thor parses this invocation's CLI options (see the Entry Point Flow in
|
|
69
|
-
# CLAUDE.md) — so checking `--verbose`/`--debug` in here would always see
|
|
70
|
-
# the pre-flag default, never what was actually passed on this run. Keep
|
|
71
|
-
# depends_on blocks limited to shaping the dependency graph itself.
|
|
72
|
-
depends_on do
|
|
73
|
-
all_checks = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
|
|
74
|
-
slow_checks = %i[c_check]
|
|
75
|
-
fast_checks = all_checks - slow_checks
|
|
76
|
-
|
|
77
|
-
[:setup, fast_checks, :teardown]
|
|
78
|
-
end
|
|
79
|
-
desc "setup, then every *_check except the slow ones, then teardown"
|
|
80
|
-
def smoke
|
|
81
|
-
puts "smoke: done"
|
|
82
|
-
end
|
|
83
|
-
end
|
data/examples/do.loki
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# Demonstrates a plain user task that runs other tasks in sequence — the
|
|
3
|
-
# `rake a b c` / `just a b c` style, which Thor's one-command dispatch
|
|
4
|
-
# doesn't offer on its own.
|
|
5
|
-
#
|
|
6
|
-
# Steps are comma-separated so a step can carry its own arguments and
|
|
7
|
-
# options; with no commas, every word is a task of its own.
|
|
8
|
-
#
|
|
9
|
-
# Run with:
|
|
10
|
-
# asgard do greet finish
|
|
11
|
-
# asgard do hello Alice, farewell Bob --formal, finish
|
|
12
|
-
#
|
|
13
|
-
# Behavior:
|
|
14
|
-
# - Each step is parsed by Thor exactly as if typed on the command line,
|
|
15
|
-
# so its arguments and options work as usual.
|
|
16
|
-
# - The sequence stops at the first failure: a failing `sh` exits the
|
|
17
|
-
# process, and a step returning :fail makes `do` return :fail (exit 1).
|
|
18
|
-
# - Every task runs at most once per asgard invocation, so
|
|
19
|
-
# `asgard do hello Alice, hello Bob` greets only Alice, and a dependency
|
|
20
|
-
# shared by several steps runs once.
|
|
21
|
-
|
|
22
|
-
require "shellwords"
|
|
23
|
-
|
|
24
|
-
class Tasks
|
|
25
|
-
# Not a Thor command (a class method), so it can be tested on its own:
|
|
26
|
-
# Tasks.do_steps("lint, hello Alice") #=> [["lint"], ["hello", "Alice"]]
|
|
27
|
-
def self.do_steps(line)
|
|
28
|
-
line.include?(",") ? line.split(",").map { |step| Shellwords.split(step) }.reject(&:empty?)
|
|
29
|
-
: Shellwords.split(line).map { |word| [word] }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
desc "do STEPS...", "Run several tasks in sequence, e.g. `asgard do hello Alice, farewell Bob --formal`"
|
|
33
|
-
stop_on_unknown_option! :do
|
|
34
|
-
def do(*words)
|
|
35
|
-
steps = self.class.do_steps(words.join(" "))
|
|
36
|
-
raise Asgard::Error, "do: no tasks given" if steps.empty?
|
|
37
|
-
|
|
38
|
-
steps.each do |name, *args|
|
|
39
|
-
raise Asgard::Error, "do: unknown task '#{name}'" if name == "do" || !self.class.all_commands.key?(name)
|
|
40
|
-
# dispatch with nil options is what Tasks.start does: Thor parses this
|
|
41
|
-
# step's argv itself. (invoke would reuse the options parsed for `do`.)
|
|
42
|
-
return :fail if self.class.send(:dispatch, nil, [name, *args], nil, shell: shell) == :fail
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
data/examples/env_usage.loki
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# Demonstrates the env() Kernel helper for reading system environment variables.
|
|
3
|
-
#
|
|
4
|
-
# env() is cleaner than ENV[] — it accepts symbols or strings, upcases
|
|
5
|
-
# automatically, and raises KeyError when a required variable is missing
|
|
6
|
-
# rather than silently returning nil.
|
|
7
|
-
#
|
|
8
|
-
# Run from examples/ or any subdirectory — loki_up locates .env automatically:
|
|
9
|
-
# asgard env_demo
|
|
10
|
-
|
|
11
|
-
class Tasks
|
|
12
|
-
# loki_up walks CWD and every ancestor until it finds .env, returning
|
|
13
|
-
# its absolute path. This works regardless of which directory asgard
|
|
14
|
-
# is run from — no hardcoded relative path needed.
|
|
15
|
-
dotenv loki_up(".env") || ".env"
|
|
16
|
-
|
|
17
|
-
desc "Print environment variable values resolved via env()"
|
|
18
|
-
def env_demo
|
|
19
|
-
puts <<~OUT
|
|
20
|
-
APP_NAME : #{env(:app_name)}
|
|
21
|
-
PORT : #{env(:port)}
|
|
22
|
-
API_KEY : #{env(:api_key)}
|
|
23
|
-
DATABASE_URL : #{env(:database_url)}
|
|
24
|
-
LOG_LEVEL : #{env(:log_level, "info")}
|
|
25
|
-
OUT
|
|
26
|
-
end
|
|
27
|
-
end
|