asgard 0.4.0 → 0.5.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.loki +3 -0
  3. data/.reek.yml +12 -0
  4. data/CHANGELOG.md +80 -0
  5. data/CLAUDE.md +10 -1
  6. data/README.md +41 -1
  7. data/docs/api.md +21 -0
  8. data/docs/index.md +1 -0
  9. data/docs/schedule.md +6 -1
  10. data/docs/watch.md +123 -0
  11. data/examples/.envrc +3 -0
  12. data/examples/watcher_examples/.envrc +3 -0
  13. data/examples/watcher_examples/.loki +54 -0
  14. data/examples/watcher_examples/README.md +112 -0
  15. data/examples/watcher_examples/bin/hello +3 -0
  16. data/examples/watcher_examples/data/records.json +1 -0
  17. data/examples/watcher_examples/docs/drafts/ideas.md +5 -0
  18. data/examples/watcher_examples/docs/index.md +4 -0
  19. data/examples/watcher_examples/lib/greeter.rb +7 -0
  20. data/lib/asgard/kernel_methods.rb +6 -0
  21. data/lib/asgard/schedule/commands.rb +102 -36
  22. data/lib/asgard/schedule/declaration.rb +61 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -0
  24. data/lib/asgard/schedule/systemd.rb +47 -0
  25. data/lib/asgard/schedule/table.rb +79 -0
  26. data/lib/asgard/schedule.rb +1 -0
  27. data/lib/asgard/tasks.rb +9 -0
  28. data/lib/asgard/version.rb +1 -1
  29. data/lib/asgard/watch/changes.rb +52 -0
  30. data/lib/asgard/watch/commands.rb +211 -0
  31. data/lib/asgard/watch/daemon.rb +123 -0
  32. data/lib/asgard/watch/declaration.rb +173 -0
  33. data/lib/asgard/watch/dispatcher.rb +55 -0
  34. data/lib/asgard/watch/reporter.rb +110 -0
  35. data/lib/asgard/watch/spawn.rb +75 -0
  36. data/lib/asgard/watch/state.rb +104 -0
  37. data/lib/asgard/watch/worker.rb +75 -0
  38. data/lib/asgard/watch.rb +51 -0
  39. data/lib/asgard.rb +7 -2
  40. data/mkdocs.yml +1 -0
  41. metadata +65 -2
@@ -0,0 +1,112 @@
1
+ # Watcher example
2
+
3
+ A small project whose tasks run when its files change. The `.loki` here
4
+ declares four watches; `lib/`, `bin/`, `docs/` and `data/` hold one
5
+ placeholder file each so the watcher has something to watch right away.
6
+
7
+ ## Before you start
8
+
9
+ The watcher runs its daemon and every triggered task as separate `asgard`
10
+ processes. If you are running asgard from a git checkout of this
11
+ repository rather than an installed gem, install the checkout as the gem
12
+ first, from the repository root:
13
+
14
+ ```bash
15
+ asgard install # builds pkg/asgard-<version>.gem and gem-installs it
16
+ gem list asgard # the checkout's version should be listed
17
+ ```
18
+
19
+ Otherwise an older asgard gem already on the machine can be the one the
20
+ spawned processes run, and it will not understand `watch`.
21
+
22
+ ## Walkthrough
23
+
24
+ From this directory:
25
+
26
+ ```bash
27
+ asgard watcher start
28
+ ```
29
+
30
+ ```
31
+ started watcher for watcher_examples (pid 12345); log: ~/.local/state/asgard/watch/watcher-examples/watcher.log
32
+ ```
33
+
34
+ Now make some changes and watch the log fill up:
35
+
36
+ ```bash
37
+ echo '# a change' >> lib/greeter.rb # lint runs, with the path in changes.modified
38
+ echo 'more' >> docs/index.md # docs runs; BUILD_ENV=demo comes from env:
39
+ echo 'more' >> docs/drafts/ideas.md # nothing: drafts/ is ignored
40
+ echo '{}' > data/new.json # reindex runs: a file was added
41
+ echo '{"edited": true}' > data/new.json # nothing: events: is added and removed only
42
+ rm data/new.json # reindex runs again: a file was removed
43
+ asgard watcher log
44
+ ```
45
+
46
+ ```
47
+ 2026-10-18 23:40:01 trigger lint task=lint lib/greeter.rb
48
+ 2026-10-18 23:40:01 spawn lint task=lint pid=12351 lib/greeter.rb
49
+ lint: lib/greeter.rb (7 lines) triggered by lint
50
+ 2026-10-18 23:40:02 exit lint task=lint pid=12351 status=0 seconds=0.41
51
+ 2026-10-18 23:40:05 trigger docs task=docs docs/index.md
52
+ 2026-10-18 23:40:05 spawn docs task=docs pid=12360 docs/index.md
53
+ docs: building for demo; changed: docs/index.md
54
+ 2026-10-18 23:40:06 exit docs task=docs pid=12360 status=0 seconds=0.40
55
+ 2026-10-18 23:40:09 trigger reindex task=reindex data/new.json
56
+ 2026-10-18 23:40:09 spawn reindex task=reindex pid=12371 data/new.json
57
+ reindex: 1 added, 0 removed; 2 data file(s) now
58
+ 2026-10-18 23:40:10 exit reindex task=reindex pid=12371 status=0 seconds=0.40
59
+ ...
60
+ ```
61
+
62
+ Each trigger line names the entry and the changed paths. Each spawn and
63
+ exit line carries the task's pid, so the lines of overlapping tasks can be
64
+ told apart; a task's own output sits between its spawn and its exit. Save
65
+ `lib/greeter.rb` twice in quick succession and the second trigger says
66
+ `(queued behind pid N)`: the same task never overlaps itself, while
67
+ different tasks run at the same time.
68
+
69
+ ```bash
70
+ asgard watcher list
71
+ ```
72
+
73
+ ```
74
+ ┌──────────┬────────────────┬──────────────────────────────────────┬──────────┬────────────────────────────────┐
75
+ │ NAME │ TASK │ WATCHES │ DAEMON │ LAST RUN │
76
+ ├──────────┼────────────────┼──────────────────────────────────────┼──────────┼────────────────────────────────┤
77
+ │ lint │ asgard lint │ lib/**/*.rb │ running │ 0 (0.4s, 2026-10-18 23:40:02) │
78
+ │ docs │ asgard docs │ docs │ running │ 0 (0.4s, 2026-10-18 23:40:06) │
79
+ │ reindex │ asgard reindex │ data/**/*.json [added, removed] │ running │ 0 (0.4s, 2026-10-18 23:40:14) │
80
+ │ lint-bin │ asgard lint │ bin/**/* │ running │ never │
81
+ └──────────┴────────────────┴──────────────────────────────────────┴──────────┴────────────────────────────────┘
82
+ ```
83
+
84
+ ```bash
85
+ asgard watcher stop
86
+ ```
87
+
88
+ A task that is still running finishes first; `stop` names it while it
89
+ waits.
90
+
91
+ ## Running the tasks by hand
92
+
93
+ Every task works without the watcher too. `changes` is then empty, which
94
+ `lint` turns into "lint everything":
95
+
96
+ ```bash
97
+ asgard lint
98
+ asgard docs
99
+ asgard reindex
100
+ ```
101
+
102
+ ## What the watcher restricts
103
+
104
+ A triggered task does not inherit the shell the watcher was started from.
105
+ It sees `PATH`, `HOME`, `USER`, `LANG` and a few other basics, the entry's
106
+ `env:`, a project `.envrc` through `direnv`, and the trigger variables
107
+ `ASGARD_WATCH`, `ASGARD_WATCH_ROOT`, `ASGARD_MODIFIED`, `ASGARD_ADDED` and
108
+ `ASGARD_REMOVED`. `asgard watcher preview` prints the exact command and
109
+ environment each entry would run.
110
+
111
+ Edit `.loki` while the watcher runs and it restarts itself with the new
112
+ declarations.
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ # Edit this file while the watcher runs: the lint-bin entry picks it up.
3
+ echo "hello from bin/"
@@ -0,0 +1 @@
1
+ { "records": ["one", "two"] }
@@ -0,0 +1,5 @@
1
+ # Drafts are ignored
2
+
3
+ Editing this file never triggers the docs entry: its declaration has
4
+ `ignore: %r{\Adrafts/}`.
5
+ more
@@ -0,0 +1,4 @@
1
+ # Watcher example
2
+
3
+ Edit this file while the watcher runs: the docs entry picks it up.
4
+ more
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Edit this file while the watcher runs: the lint entry picks it up.
4
+ module Greeter
5
+ def self.greet(name) = "Hello, #{name}!"
6
+ end
7
+ # a change
@@ -16,6 +16,12 @@ module Kernel
16
16
  end
17
17
  module_function :env
18
18
 
19
+ # The files the watcher saw change when it triggered this task, as an
20
+ # Asgard::Changes (modified, added, removed, all). Every list is empty
21
+ # when the task is run by hand.
22
+ def changes = Asgard::Changes.current
23
+ module_function :changes
24
+
19
25
  def loki_up(name = ".loki")
20
26
  dir = Pathname.new(Dir.pwd)
21
27
  loop do
@@ -54,6 +54,11 @@ module Asgard
54
54
 
55
55
  def schedule_summary(spec) = "#{Schedule.command_line(spec[:task], spec[:args])} (#{Schedule.describe(**spec)})"
56
56
 
57
+ # One `list` row for this project: its declaration (if still declared) plus live state.
58
+ def schedule_row(name)
59
+ { name:, **Schedule.declared_columns(schedules[name], "no longer declared"), **Schedule.live_columns(scheduler, name) }
60
+ end
61
+
57
62
  def schedule_install(spec, asgard:, direnv:)
58
63
  name = spec[:name]
59
64
  state = scheduler.install(spec, asgard:, direnv:)
@@ -65,16 +70,6 @@ module Asgard
65
70
  scheduler.uninstall(name)
66
71
  puts "removed #{name}"
67
72
  end
68
-
69
- def require_installed!(name)
70
- abort "#{name} is not installed (see `asgard schedule list`)." unless scheduler.installed_names.include?(name)
71
- end
72
-
73
- def require_known!(name)
74
- return if schedules.key?(name) || scheduler.installed_names.include?(name)
75
-
76
- abort "#{name} is neither declared nor installed (see `asgard schedule list`)."
77
- end
78
73
  end
79
74
 
80
75
  desc "preview", "Show the job files install would write, without installing them"
@@ -100,61 +95,132 @@ module Asgard
100
95
  scheduler.notes.each { |note| $stderr.puts note }
101
96
  end
102
97
 
98
+ no_commands do
99
+ # [backend, entry name] for an installed entry of this or any project: a
100
+ # bare name, or project/name when several projects use the same one. nil if none.
101
+ def find_installed(name)
102
+ return [scheduler, name] if scheduler.installed_names.include?(name)
103
+
104
+ project, entry = name.include?("/") ? name.split("/", 2) : [nil, name]
105
+ matches = scheduler.installed_entries.select { |slug, found| found == entry && (project.nil? || slug == project) }
106
+ abort "#{name} is in several projects (#{matches.map(&:first).join(', ')}); name one as project/#{entry}." if matches.size > 1
107
+
108
+ matches.first&.then { |slug, found| [backend_for(slug), found] }
109
+ end
110
+
111
+ def resolve_installed(name) = find_installed(name) || abort("#{name} is not installed (see `asgard schedule list --all`).")
112
+
113
+ # Like resolve_installed, but a declared entry of this project counts even before it is installed.
114
+ def resolve_known(name)
115
+ return [scheduler, name] if schedules.key?(name)
116
+
117
+ find_installed(name) || abort("#{name} is neither declared nor installed (see `asgard schedule list --all`).")
118
+ end
119
+
120
+ # True when list should cover every project: asked for with --all, or
121
+ # there is no .loki here, so no single project to scope to.
122
+ def list_all? = options[:all] || Asgard.find_task_file.nil?
123
+
124
+ # What `list` says when there is nothing to show; a project-scoped list
125
+ # also tells the user how to look further.
126
+ def no_schedules_message
127
+ backend = scheduler.scheduler
128
+ return "No scheduled tasks installed (#{backend})." if list_all?
129
+
130
+ "No scheduled tasks installed for #{schedule_project} (#{backend}).\n" \
131
+ "Use `asgard schedule list --all` to see all currently scheduled tasks."
132
+ end
133
+
134
+ # The rows to list, grouped under a project title: { "Project: name (/path)" => [row, ...] }
135
+ # for every project, or { nil => [row, ...] } for this one. Empty groups drop out.
136
+ def list_groups
137
+ groups = list_all? ? all_schedule_groups : { nil => scheduler.installed_names.map { |name| schedule_row(name) } }
138
+ groups.reject { |_, rows| rows.empty? }
139
+ end
140
+
141
+ # Every installed entry on the machine, grouped by project.
142
+ def all_schedule_groups
143
+ by_project = scheduler.installed_entries.group_by(&:first)
144
+ by_project.to_h { |slug, entries| [project_title(slug, entries.first.last), entry_rows(entries)] }
145
+ end
146
+
147
+ # "Project: slug (/full/path)", the path read from one of the project's job files.
148
+ def project_title(slug, name)
149
+ dir = slug == current_slug ? schedule_root : backend_for(slug).installed_directory(name)
150
+ ["Project: #{slug}", ("(#{dir})" if dir)].compact.join(" ")
151
+ end
152
+
153
+ def current_slug = Asgard.find_task_file && Schedule.slug(schedule_project)
154
+
155
+ def backend_for(slug) = Schedule.backend_class.new(project: slug, root: schedule_root)
156
+
157
+ def entry_rows(entries) = entries.map { |slug, name| entry_row(slug, name) }
158
+
159
+ # This project's entries keep their declaration columns; see other_project_row.
160
+ def entry_row(slug, name)
161
+ return schedule_row(name) if slug == current_slug
162
+
163
+ other_project_row(slug, name)
164
+ end
165
+
166
+ # A row for an entry of another project: its .loki isn't loaded, so the
167
+ # command and schedule are read back from the installed job files.
168
+ def other_project_row(slug, name)
169
+ backend = backend_for(slug)
170
+ shown = backend.installed_columns(name) || Schedule.declared_columns(nil, "(unreadable)")
171
+ { name:, **shown, **Schedule.live_columns(backend, name) }
172
+ end
173
+ end
174
+
103
175
  desc "list", "Show this project's installed entries, their state, and last exit status"
176
+ method_option :all, aliases: "-a", type: :boolean, desc: "List every project's entries, not just this one's"
104
177
  def list
105
- names = scheduler.installed_names
106
- return puts "No scheduled tasks installed for #{schedule_project} (#{scheduler.scheduler})." if names.empty?
107
-
108
- names.each do |name|
109
- status = scheduler.status(name)
110
- state = case status[:state]
111
- when :active then "active; last exit: #{status[:last_exit] || 'never run'}"
112
- when :stopped then "stopped"
113
- else "not loaded"
114
- end
115
- spec = schedules[name]
116
- timing = spec ? schedule_summary(spec) : "no longer declared"
117
- puts "#{name} #{timing} [#{state}] log: #{scheduler.log_path(name)}"
118
- end
178
+ groups = list_groups
179
+ return puts(no_schedules_message) if groups.empty?
180
+
181
+ puts groups.map { |title, rows| Schedule::Table.draw(rows, title:) }.join("\n\n")
182
+ puts "\nLogs: #{File.dirname(scheduler.log_path(groups.values.first.first[:name]))} (asgard schedule log NAME)"
119
183
  end
120
184
 
121
185
  desc "stop NAME", "Stop one scheduled entry; it stays stopped across reboots and installs until started"
122
186
  def stop(name)
123
- require_installed!(name)
124
- scheduler.stop(name)
187
+ backend, entry = resolve_installed(name)
188
+ backend.stop(entry)
125
189
  puts "stopped #{name}"
126
190
  end
127
191
 
128
192
  desc "start NAME", "Start a stopped entry, or install and start just this declared entry"
129
193
  def start(name)
130
- require_known!(name)
131
- spec = schedules[name]
194
+ backend, entry = resolve_known(name)
195
+ own = backend.equal?(scheduler)
196
+ spec = schedules[entry] if own
132
197
 
133
198
  if spec
134
199
  declared_schedules # validates the task exists
135
200
  scheduler.install(spec, asgard: schedule_asgard, direnv: schedule_direnv) # refresh the job files
136
201
  else
137
- $stderr.puts "schedule: #{name} is no longer declared; starting its installed job as-is"
202
+ why = own ? "is no longer declared" : "belongs to another project"
203
+ $stderr.puts "schedule: #{name} #{why}; starting its installed job as-is"
138
204
  end
139
- scheduler.start(name)
205
+ backend.start(entry)
140
206
  puts "started #{name}"
141
207
  end
142
208
 
143
209
  desc "trigger NAME", "Run an installed entry now, under the scheduler's environment"
144
210
  def trigger(name)
145
- require_installed!(name)
146
- abort "#{name} is stopped; `asgard schedule start #{name}` first." if scheduler.status(name)[:state] == :stopped
211
+ backend, entry = resolve_installed(name)
212
+ abort "#{name} is stopped; `asgard schedule start #{name}` first." if backend.status(entry)[:state] == :stopped
147
213
 
148
- scheduler.trigger(name)
149
- puts "triggered #{name}; output goes to #{scheduler.log_path(name)}"
214
+ backend.trigger(entry)
215
+ puts "triggered #{name}; output goes to #{backend.log_path(entry)}"
150
216
  end
151
217
 
152
218
  desc "log NAME", "Print the named entry's log file to STDOUT"
153
219
  method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
154
220
  def log(name)
155
- require_known!(name)
221
+ backend, entry = resolve_known(name)
156
222
 
157
- path = scheduler.log_path(name)
223
+ path = backend.log_path(entry)
158
224
  return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
159
225
  return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
160
226
 
@@ -24,6 +24,9 @@ module Asgard
24
24
  # #start(name) / #stop(name) # stop persists across reboots and reinstalls
25
25
  # #trigger(name) # run once now, under the scheduler
26
26
  # #installed_names # => ["demo", ...] for this project
27
+ # #installed_entries # => [[project_slug, "demo"], ...] for every project
28
+ # #installed_columns(name) # => { command:, schedule: } read back from the job files, or nil
29
+ # #installed_directory(name) # => the project root the job runs in, or nil
27
30
  # #status(name) # => { state: :active|:stopped|:not_loaded, last_exit: String|nil }
28
31
  # #log_path(name) # => path the job's output is appended to
29
32
  # #notes # => [String] platform hints to show after install
@@ -121,6 +124,64 @@ module Asgard
121
124
  "#{Array(at).join(', ')} #{Array(on).join(', ')}"
122
125
  end
123
126
 
127
+ # For the `list` table: like describe, but a run of hourly times
128
+ # collapses ("10:00, 11:00 ... 17:00" => "10:00-17:00 hourly").
129
+ def describe_compact(at: nil, on: :daily, every: nil, **)
130
+ return "every #{every}s" if every
131
+
132
+ "#{compact_times(Array(at))} #{Array(on).join(', ')}"
133
+ end
134
+
135
+ # The command and schedule columns of a `list` row for a declared entry.
136
+ def list_columns(spec) = { command: command_line(spec[:task], spec[:args]), schedule: describe_compact(**spec) }
137
+
138
+ # The command and schedule columns of a `list` row: the declaration's, or
139
+ # `missing` in the schedule column when there is none.
140
+ def declared_columns(spec, missing) = spec ? list_columns(spec) : { command: "-", schedule: missing }
141
+
142
+ # The state and last-exit columns of a `list` row, from a backend's live status.
143
+ def live_columns(backend, name)
144
+ state, last_exit = backend.status(name).values_at(:state, :last_exit)
145
+ { state: state.to_s.tr("_", " "), last_exit: state == :active ? (last_exit || "never run") : "-" }
146
+ end
147
+
148
+ # The command a job runs, from its program arguments: everything from
149
+ # `asgard` on, so a direnv wrapper and the asgard install path drop out.
150
+ def command_from_arguments(arguments)
151
+ start = arguments.index { |word| File.basename(word) == "asgard" }
152
+ Shellwords.join(["asgard", *arguments.drop(start ? start + 1 : 0)])
153
+ end
154
+
155
+ # describe_compact for calendar entries (the shape `calendar` returns), one
156
+ # group per distinct set of days: "17:30 weekdays; 09:00 saturday".
157
+ def describe_calendar(entries)
158
+ entries.group_by { |entry| entry[:days]&.sort }.map { |days, group| describe_days(days, group) }.join("; ")
159
+ end
160
+
161
+ def describe_days(days, group)
162
+ describe_compact(at: group.map { |entry| format("%<hour>02d:%<minute>02d", entry) }, on: day_names(days))
163
+ end
164
+
165
+ # The command and schedule columns for a job read back from its files.
166
+ def installed_columns(arguments:, calendar:, every:)
167
+ { command: command_from_arguments(arguments), schedule: every ? "every #{every}s" : describe_calendar(calendar) }
168
+ end
169
+
170
+ # Weekday numbers back to what on: took: nil => :daily, [1, 2, 3, 4, 5] => :weekdays, [5] => [:friday].
171
+ def day_names(days)
172
+ return :daily unless days
173
+
174
+ names = days.map { |day| DAYS.fetch(day) }
175
+ DAY_GROUPS.key(names) || names
176
+ end
177
+
178
+ def compact_times(times)
179
+ parsed = times.map { |time| parse_time(time) }
180
+ hourly = parsed.size >= 3 && parsed.map(&:last).uniq.size == 1 &&
181
+ parsed.each_cons(2).all? { |(hour, _), (next_hour, _)| next_hour == hour + 1 }
182
+ hourly ? "#{times.first}-#{times.last} hourly" : times.join(", ")
183
+ end
184
+
124
185
  # "17:30" => [17, 30]
125
186
  def parse_time(time)
126
187
  shown = time.inspect
@@ -64,6 +64,40 @@ module Asgard
64
64
  end
65
65
  end
66
66
 
67
+ # The parts of a plist (as `plist` writes it) that say what runs and when:
68
+ # { arguments: [String], calendar: [{ hour:, minute:, days: [Integer]|nil }], every: Integer|nil }
69
+ def self.parse_plist(xml)
70
+ {
71
+ arguments: array_body(xml, "ProgramArguments").scan(%r{<string>(.*?)</string>}m).flatten.map { |text| unescape(text) },
72
+ calendar: calendar_entries(array_body(xml, "StartCalendarInterval").scan(%r{<dict>(.*?)</dict>}m).flatten),
73
+ every: xml[%r{<key>StartInterval</key>\s*<integer>(\d+)</integer>}, 1]&.to_i
74
+ }
75
+ end
76
+
77
+ # StartCalendarInterval dict bodies => one entry per time, days collected.
78
+ def self.calendar_entries(dicts)
79
+ dicts.map { |body| parse_dict(body) }
80
+ .group_by { |dict| dict.values_at("Hour", "Minute") }
81
+ .map { |(hour, minute), group| calendar_entry(hour, minute, group) }
82
+ end
83
+
84
+ # <key>Hour</key><integer>13</integer>... => { "Hour" => 13, ... }
85
+ def self.parse_dict(body) = body.scan(%r{<key>(\w+)</key>\s*<integer>(\d+)</integer>}).to_h { |key, num| [key, num.to_i] }
86
+
87
+ # group: the Weekday-bearing dicts for one time; days is nil when there are none (every day).
88
+ def self.calendar_entry(hour, minute, group)
89
+ days = group.filter_map { |dict| dict["Weekday"] }
90
+ { hour:, minute:, days: days.empty? ? nil : days }
91
+ end
92
+
93
+ # The text inside the <array> that follows <key>key</key>.
94
+ def self.array_body(xml, key) = xml[%r{<key>#{key}</key>\s*<array>(.*?)</array>}m, 1].to_s
95
+
96
+ # The WorkingDirectory (the project root) of a plist, or nil.
97
+ def self.parse_directory(xml) = xml[%r{<key>WorkingDirectory</key>\s*<string>(.*?)</string>}m, 1]&.then { |text| unescape(text) }
98
+
99
+ def self.unescape(text) = text.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
100
+
67
101
  def self.escape(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
68
102
 
69
103
  # ---- backend API ------------------------------------------------------
@@ -136,6 +170,27 @@ module Asgard
136
170
  Dir.glob(plist_path("*")).map { |path| File.basename(path, ".plist").delete_prefix(prefix) }.sort
137
171
  end
138
172
 
173
+ # Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
174
+ def installed_entries
175
+ paths = Dir.glob(File.join(@home, "Library", "LaunchAgents", "#{LABEL_PREFIX}.*.plist"))
176
+ paths.map { |path| File.basename(path, ".plist").delete_prefix("#{LABEL_PREFIX}.").split(".", 2) }
177
+ .select { |entry| entry.size == 2 }.sort
178
+ end
179
+
180
+ # { command:, schedule: } as installed, read from the plist; nil when there isn't one.
181
+ def installed_columns(name)
182
+ path = plist_path(name)
183
+ return unless File.exist?(path)
184
+
185
+ Schedule.installed_columns(**self.class.parse_plist(File.read(path)))
186
+ end
187
+
188
+ # The project root the job runs in, read from the plist; nil when there isn't one.
189
+ def installed_directory(name)
190
+ path = plist_path(name)
191
+ self.class.parse_directory(File.read(path)) if File.exist?(path)
192
+ end
193
+
139
194
  def status(name)
140
195
  out, ok = run("launchctl", "print", target(name))
141
196
  return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
@@ -76,6 +76,30 @@ module Asgard
76
76
  UNIT
77
77
  end
78
78
 
79
+ # ExecStart= words, undoing `quote`.
80
+ def self.parse_exec_start(service)
81
+ line = service[/^ExecStart=(.*)$/, 1].to_s
82
+ line.scan(/"((?:[^"\\]|\\.)*)"/).flatten.map { |word| word.gsub(/\\(.)/, '\1').gsub("$$", "$").gsub("%%", "%") }
83
+ end
84
+
85
+ # "Mon,Fri *-*-* 17:30:00" => { hour: 17, minute: 30, days: [1, 5] }
86
+ def self.parse_on_calendar(text)
87
+ days = text[/\A([A-Z][a-z]{2}(?:,[A-Z][a-z]{2})*) /, 1]
88
+ hour, minute = text.scan(/(\d+):(\d+)/).first.map(&:to_i)
89
+ { hour:, minute:, days: days&.split(",")&.map { |abbr| DAY_ABBREVIATIONS.index(abbr) } }
90
+ end
91
+
92
+ # The WorkingDirectory= (the project root) of a service unit, or nil.
93
+ def self.parse_directory(service) = service[/^WorkingDirectory=(.*)$/, 1]&.gsub("%%", "%")
94
+
95
+ # { calendar: [entry], every: Integer|nil } from a timer unit.
96
+ def self.parse_timer(timer)
97
+ {
98
+ calendar: timer.scan(/^OnCalendar=(.*)$/).flatten.map { |text| parse_on_calendar(text) },
99
+ every: timer[/^OnUnitActiveSec=(\d+)/, 1]&.to_i
100
+ }
101
+ end
102
+
79
103
  # `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
80
104
  def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
81
105
 
@@ -156,6 +180,29 @@ module Asgard
156
180
  Dir.glob(timer_path("*")).map { |path| File.basename(path, ".timer").delete_prefix(prefix) }.sort
157
181
  end
158
182
 
183
+ # Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
184
+ def installed_entries
185
+ paths = Dir.glob(File.join(@config, "systemd", "user", "#{UNIT_PREFIX}.*.timer"))
186
+ paths.map { |path| File.basename(path, ".timer").delete_prefix("#{UNIT_PREFIX}.").split(".", 2) }
187
+ .select { |entry| entry.size == 2 }.sort
188
+ end
189
+
190
+ # { command:, schedule: } as installed, read from the unit files; nil when there are none.
191
+ def installed_columns(name)
192
+ paths = [service_path(name), timer_path(name)]
193
+ return unless paths.all? { |path| File.exist?(path) }
194
+
195
+ klass = self.class
196
+ service, timer = paths.map { |path| File.read(path) }
197
+ Schedule.installed_columns(arguments: klass.parse_exec_start(service), **klass.parse_timer(timer))
198
+ end
199
+
200
+ # The project root the job runs in, read from the service unit; nil when there isn't one.
201
+ def installed_directory(name)
202
+ path = service_path(name)
203
+ self.class.parse_directory(File.read(path)) if File.exist?(path)
204
+ end
205
+
159
206
  def status(name)
160
207
  _, active = systemctl("is-active", "--quiet", timer(name))
161
208
  state = if active then :active
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ module Schedule
5
+ # The table `asgard schedule list` prints, drawn by tty-table. rows are
6
+ # hashes with :name, :command, :schedule, :state and :last_exit (strings).
7
+ # A cell that doesn't fit wraps onto extra lines; only COMMAND and SCHEDULE
8
+ # give up width, so the short columns always stay whole.
9
+ class Table
10
+ HEADERS = ["NAME", "COMMAND", "SCHEDULE", "STATE", "LAST EXIT"].freeze
11
+ FLEXIBLE = [1, 2].freeze
12
+ MIN_WIDTH = 14
13
+ EXTRA = 4 # a space either side of a cell, plus slack for tty-table's rounding
14
+ BORDERS = HEADERS.size + 1 # one before each column and one after the last
15
+
16
+ # Columns available for output: the terminal's when io is one, else
17
+ # $COLUMNS, else a roomy default (piped output shouldn't wrap at 80).
18
+ def self.terminal_width(io = $stdout, env: ENV, default: 120)
19
+ return Integer(env["COLUMNS"], exception: false) || default unless io.tty?
20
+
21
+ require "tty-screen"
22
+ TTY::Screen.width
23
+ end
24
+
25
+ # rows rendered for the terminal, under title when given: colored when io
26
+ # is a tty and NO_COLOR is unset. headers: relabels the five columns.
27
+ def self.draw(rows, title: nil, io: $stdout, headers: HEADERS)
28
+ table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io), headers:).render
29
+ [title, table].compact.join("\n")
30
+ end
31
+
32
+ # color: styles the header, state and last exit with ANSI codes.
33
+ def initialize(rows, color:, width:, headers: HEADERS)
34
+ require "pastel"
35
+
36
+ @headers = headers
37
+ @cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
38
+ @pastel = Pastel.new(enabled: color)
39
+ @width = width
40
+ @column_widths = natural_widths
41
+ shrink_widest while overflowing? && shrinkable?
42
+ end
43
+
44
+ def render
45
+ require "tty-table"
46
+
47
+ grid = TTY::Table.new(header: @headers.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
48
+ grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
49
+ end
50
+
51
+ # Width of each column, padding included. Every column starts at its
52
+ # natural width; when the table would be wider than the screen, the
53
+ # flexible columns give up a character at a time until it fits.
54
+ attr_reader :column_widths
55
+
56
+ # Rendered width: the columns plus their borders.
57
+ def total_width = column_widths.sum + BORDERS
58
+
59
+ private
60
+
61
+ def natural_widths = [@headers, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
62
+
63
+ def overflowing? = total_width > @width
64
+
65
+ def widest_flexible = FLEXIBLE.max_by { |index| @column_widths[index] }
66
+
67
+ def shrinkable? = @column_widths[widest_flexible] > MIN_WIDTH
68
+
69
+ def shrink_widest = @column_widths[widest_flexible] -= 1
70
+
71
+ def styled(cells)
72
+ *lead, state, last_exit = cells
73
+ state_color = { "active" => :green, "running" => :green, "stopped" => :yellow }.fetch(state, :red)
74
+ exit_color = last_exit.match?(/\A[1-9]\d*\b/) ? :red : :dim
75
+ [*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
76
+ end
77
+ end
78
+ end
79
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "schedule/declaration"
4
4
  require_relative "schedule/launchd"
5
5
  require_relative "schedule/systemd"
6
+ require_relative "schedule/table"
6
7
  require_relative "schedule/commands"
7
8
 
8
9
  module Asgard
data/lib/asgard/tasks.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "schedule"
4
+ require_relative "watch"
4
5
 
5
6
  # Tasks is the single conventional entry point for all .loki files.
6
7
  # It is pre-defined by the gem so .loki files never need to declare a class.
@@ -38,10 +39,18 @@ class Tasks < Asgard::Base
38
39
  # Class-level `schedule :task, at: "HH:MM"` declarations (see Asgard::Schedule).
39
40
  extend Asgard::Schedule::DSL
40
41
 
42
+ # Class-level `watch :task, paths: "lib"` declarations (see Asgard::Watch).
43
+ extend Asgard::Watch::DSL
44
+
41
45
  # Gem-owned, so the command is _schedule; the map keeps `asgard schedule`
42
46
  # as the name users type, and ancestor_name keeps it in subcommand help.
43
47
  desc "schedule SUBCOMMAND", "Manage schedules declared with `schedule :task, ...` (launchd on macOS, systemd on Linux)"
44
48
  subcommand "_schedule", Asgard::Schedule::Commands
45
49
  map "schedule" => :_schedule
46
50
  Asgard::Schedule::Commands.commands.each_value { |command| command.ancestor_name = "schedule" }
51
+
52
+ desc "watcher SUBCOMMAND", "Run and manage the file watcher declared with `watch :task, paths: ...`"
53
+ subcommand "_watcher", Asgard::Watch::Commands
54
+ map "watcher" => :_watcher
55
+ Asgard::Watch::Commands.commands.each_value { |command| command.ancestor_name = "watcher" }
47
56
  end