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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. 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
@@ -1,4 +0,0 @@
1
- APP_NAME=my_app
2
- PORT=4000
3
- API_KEY=secret-key-abc123
4
- DATABASE_URL=postgres://localhost/my_app_dev
data/examples/.envrc DELETED
@@ -1,3 +0,0 @@
1
- # asgard/examples/.envrc
2
-
3
- source_up
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
@@ -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
@@ -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
@@ -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