asgard 0.3.3 → 0.4.1

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.
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Asgard
6
+ module Schedule
7
+ # macOS backend: each declaration becomes a launchd user agent
8
+ # (~/Library/LaunchAgents/com.madbomber.asgard.<project>.<name>.plist).
9
+ # launchd runs a calendar job missed while the Mac slept as soon as it
10
+ # wakes. Implements the backend API documented in declaration.rb.
11
+ class Launchd
12
+ LABEL_PREFIX = "com.madbomber.asgard"
13
+
14
+ # ---- pure helpers -----------------------------------------------------
15
+
16
+ def self.label_prefix(project) = "#{LABEL_PREFIX}.#{Schedule.slug(project)}."
17
+
18
+ def self.label(project, name) = "#{label_prefix(project)}#{name}"
19
+
20
+ # One StartCalendarInterval entry per (time, weekday) pair.
21
+ def self.calendar_intervals(at:, on: :daily)
22
+ Schedule.calendar(at:, on:).flat_map do |entry|
23
+ (entry[:days] || [nil]).map { |day| { "Hour" => entry[:hour], "Minute" => entry[:minute], "Weekday" => day }.compact }
24
+ end
25
+ end
26
+
27
+ # Labels marked disabled in `launchctl print-disabled` output
28
+ # ("label" => disabled, or "label" => true on older macOS).
29
+ def self.disabled_labels(output) = output.scan(/"([^"]+)"\s*=>\s*(disabled|true)\b/).map(&:first)
30
+
31
+ def self.plist(label:, arguments:, working_directory:, environment:, log_path:, intervals: nil, every: nil)
32
+ dict = {
33
+ "Label" => label,
34
+ "ProgramArguments" => arguments,
35
+ "WorkingDirectory" => working_directory,
36
+ "EnvironmentVariables" => environment,
37
+ "StandardOutPath" => log_path,
38
+ "StandardErrorPath" => log_path
39
+ }
40
+ every ? dict["StartInterval"] = every : dict["StartCalendarInterval"] = intervals
41
+
42
+ <<~XML
43
+ <?xml version="1.0" encoding="UTF-8"?>
44
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45
+ <plist version="1.0">
46
+ #{to_xml(dict)}
47
+ </plist>
48
+ XML
49
+ end
50
+
51
+ def self.to_xml(value, indent = "")
52
+ case value
53
+ in Hash
54
+ body = value.flat_map { |key, item| ["#{indent} <key>#{escape(key)}</key>", to_xml(item, "#{indent} ")] }
55
+ ["#{indent}<dict>", *body, "#{indent}</dict>"].join("\n")
56
+ in Array
57
+ ["#{indent}<array>", *value.map { |element| to_xml(element, "#{indent} ") }, "#{indent}</array>"].join("\n")
58
+ in Integer
59
+ "#{indent}<integer>#{value}</integer>"
60
+ in true | false
61
+ "#{indent}<#{value}/>"
62
+ else
63
+ "#{indent}<string>#{escape(value)}</string>"
64
+ end
65
+ end
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
+
101
+ def self.escape(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
102
+
103
+ # ---- backend API ------------------------------------------------------
104
+
105
+ def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, uid: Process.uid)
106
+ @project = project
107
+ @root = root
108
+ @home = home
109
+ @runner = runner
110
+ @domain = "gui/#{uid}"
111
+ end
112
+
113
+ def scheduler = "launchd"
114
+
115
+ def label(name) = self.class.label(@project, name)
116
+
117
+ def plist_path(name) = File.join(@home, "Library", "LaunchAgents", "#{label(name)}.plist")
118
+
119
+ def log_path(name) = File.join(@home, "Library", "Logs", "asgard", "#{label(name)}.log")
120
+
121
+ def files(spec, asgard:, direnv:)
122
+ name = spec[:name]
123
+ every = spec[:every]
124
+ klass = self.class
125
+ plist = klass.plist(
126
+ label: label(name),
127
+ arguments: Schedule.program_arguments(spec[:task], spec[:args], root: @root, asgard:, direnv:),
128
+ working_directory: @root,
129
+ environment: Schedule.environment(spec),
130
+ log_path: log_path(name),
131
+ intervals: every ? nil : klass.calendar_intervals(at: spec[:at], on: spec[:on]),
132
+ every:
133
+ )
134
+ { plist_path(name) => plist }
135
+ end
136
+
137
+ def install(spec, asgard:, direnv:)
138
+ name = spec[:name]
139
+ plist = plist_path(name)
140
+ FileUtils.mkdir_p [File.dirname(plist), File.dirname(log_path(name))]
141
+ files(spec, asgard:, direnv:).each { |path, content| File.write(path, content) }
142
+ run! "plutil", "-lint", "-s", plist
143
+ unload(name)
144
+ return :stopped if stopped?(name)
145
+
146
+ run! "launchctl", "bootstrap", @domain, plist
147
+ :active
148
+ end
149
+
150
+ def uninstall(name)
151
+ unload(name)
152
+ run "launchctl", "enable", target(name) # clear any stop
153
+ FileUtils.rm_f(plist_path(name))
154
+ end
155
+
156
+ def start(name)
157
+ run! "launchctl", "enable", target(name)
158
+ run! "launchctl", "bootstrap", @domain, plist_path(name) unless loaded?(name)
159
+ end
160
+
161
+ def stop(name)
162
+ run! "launchctl", "disable", target(name)
163
+ unload(name)
164
+ end
165
+
166
+ def trigger(name) = run!("launchctl", "kickstart", target(name))
167
+
168
+ def installed_names
169
+ prefix = self.class.label_prefix(@project)
170
+ Dir.glob(plist_path("*")).map { |path| File.basename(path, ".plist").delete_prefix(prefix) }.sort
171
+ end
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
+
194
+ def status(name)
195
+ out, ok = run("launchctl", "print", target(name))
196
+ return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
197
+
198
+ code = out[/last exit code = (\d+)/, 1]
199
+ { state: :active, last_exit: code }
200
+ end
201
+
202
+ def notes = []
203
+
204
+ private
205
+
206
+ def target(name) = "#{@domain}/#{label(name)}"
207
+
208
+ def loaded?(name) = run("launchctl", "print", target(name)).last
209
+
210
+ def stopped?(name)
211
+ out, = run("launchctl", "print-disabled", @domain)
212
+ self.class.disabled_labels(out).include?(label(name))
213
+ end
214
+
215
+ # bootout returns before the job is fully gone; bootstrapping the same
216
+ # label too soon fails with "Input/output error", so wait for it.
217
+ def unload(name)
218
+ return unless loaded?(name)
219
+
220
+ run "launchctl", "bootout", target(name)
221
+ 20.times do
222
+ break unless loaded?(name)
223
+
224
+ sleep 0.1
225
+ end
226
+ end
227
+
228
+ def run(*argv) = @runner.call(*argv)
229
+
230
+ def run!(*argv)
231
+ out, ok = run(*argv)
232
+ raise Error, "#{argv.join(' ')} failed: #{out.strip}" unless ok
233
+
234
+ out
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "etc"
5
+
6
+ module Asgard
7
+ module Schedule
8
+ # Linux backend: each declaration becomes a systemd user service + timer
9
+ # pair (~/.config/systemd/user/asgard.<project>.<name>.{service,timer}).
10
+ # Calendar timers are Persistent=, so a run missed while the machine was
11
+ # off or asleep happens on the next boot/wake. Requires systemd 240+ (for
12
+ # StandardOutput=append:). User timers only run while you are logged in
13
+ # unless lingering is enabled (`loginctl enable-linger`); #notes says so.
14
+ # Implements the backend API documented in declaration.rb.
15
+ class Systemd
16
+ UNIT_PREFIX = "asgard"
17
+ DAY_ABBREVIATIONS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
18
+
19
+ # ---- pure helpers -----------------------------------------------------
20
+
21
+ def self.unit_prefix(project) = "#{UNIT_PREFIX}.#{Schedule.slug(project)}."
22
+
23
+ def self.unit(project, name) = "#{unit_prefix(project)}#{name}"
24
+
25
+ # { hour: 17, minute: 30, days: [1, 5] } => "Mon,Fri *-*-* 17:30:00"
26
+ def self.on_calendar(entry)
27
+ days = entry[:days]&.map { |day| DAY_ABBREVIATIONS.fetch(day) }&.join(",")
28
+ [days, format("*-*-* %<hour>02d:%<minute>02d:00", entry)].compact.join(" ")
29
+ end
30
+
31
+ # systemd expands %specifiers everywhere; % must be doubled to be literal.
32
+ def self.escape_specifiers(text) = text.to_s.gsub("%", "%%")
33
+
34
+ # A double-quoted word for ExecStart=/Environment=. $ is doubled too so
35
+ # ExecStart= doesn't expand it as a variable.
36
+ def self.quote(text)
37
+ %("#{escape_specifiers(text).gsub('\\') { '\\\\' }.gsub('"', '\\"').gsub('$', '$$')}")
38
+ end
39
+
40
+ def self.service_unit(description:, arguments:, working_directory:, environment:, log_path:)
41
+ env_lines = environment.map { |key, value| "Environment=#{quote("#{key}=#{value}")}" }
42
+ log = escape_specifiers(log_path)
43
+ <<~UNIT
44
+ [Unit]
45
+ Description=#{escape_specifiers(description)}
46
+
47
+ [Service]
48
+ Type=oneshot
49
+ WorkingDirectory=#{escape_specifiers(working_directory)}
50
+ #{env_lines.join("\n")}
51
+ ExecStart=#{arguments.map { |arg| quote(arg) }.join(' ')}
52
+ StandardOutput=append:#{log}
53
+ StandardError=append:#{log}
54
+ UNIT
55
+ end
56
+
57
+ # every: runs one interval after the timer starts, then one interval
58
+ # after each run, matching launchd's StartInterval.
59
+ def self.timer_unit(description:, service:, calendar: nil, every: nil)
60
+ schedule = if every
61
+ ["OnActiveSec=#{every}", "OnUnitActiveSec=#{every}"]
62
+ else
63
+ [*calendar.map { |entry| "OnCalendar=#{on_calendar(entry)}" }, "Persistent=true"]
64
+ end
65
+ <<~UNIT
66
+ [Unit]
67
+ Description=#{escape_specifiers(description)}
68
+
69
+ [Timer]
70
+ #{schedule.join("\n")}
71
+ AccuracySec=1s
72
+ Unit=#{service}
73
+
74
+ [Install]
75
+ WantedBy=timers.target
76
+ UNIT
77
+ end
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
+
103
+ # `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
104
+ def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
105
+
106
+ # ---- backend API ------------------------------------------------------
107
+
108
+ def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, env: ENV, user: Etc.getpwuid(Process.uid).name)
109
+ @project = project
110
+ @root = root
111
+ @runner = runner
112
+ @user = user
113
+ @config = env["XDG_CONFIG_HOME"] || File.join(home, ".config")
114
+ @state = env["XDG_STATE_HOME"] || File.join(home, ".local", "state")
115
+ end
116
+
117
+ def scheduler = "systemd"
118
+
119
+ def unit(name) = self.class.unit(@project, name)
120
+
121
+ def service_path(name) = File.join(@config, "systemd", "user", "#{unit(name)}.service")
122
+
123
+ def timer_path(name) = File.join(@config, "systemd", "user", "#{unit(name)}.timer")
124
+
125
+ def log_path(name) = File.join(@state, "asgard", "#{unit(name)}.log")
126
+
127
+ def files(spec, asgard:, direnv:)
128
+ name, task, args, every = spec.values_at(:name, :task, :args, :every)
129
+ klass = self.class
130
+ description = "#{Schedule.command_line(task, args)} (#{@project})"
131
+ service = klass.service_unit(
132
+ description:,
133
+ arguments: Schedule.program_arguments(task, args, root: @root, asgard:, direnv:),
134
+ working_directory: @root,
135
+ environment: Schedule.environment(spec),
136
+ log_path: log_path(name)
137
+ )
138
+ timer = klass.timer_unit(
139
+ description:,
140
+ service: "#{unit(name)}.service",
141
+ calendar: every ? nil : Schedule.calendar(at: spec[:at], on: spec[:on]),
142
+ every:
143
+ )
144
+ { service_path(name) => service, timer_path(name) => timer }
145
+ end
146
+
147
+ # A timer that exists but is disabled was stopped on purpose; keep it so.
148
+ def install(spec, asgard:, direnv:)
149
+ name = spec[:name]
150
+ timer_file = timer_path(name)
151
+ timer_unit = timer(name)
152
+ stopped = File.exist?(timer_file) && !enabled?(name)
153
+ FileUtils.mkdir_p [File.dirname(timer_file), File.dirname(log_path(name))]
154
+ files(spec, asgard:, direnv:).each { |path, content| File.write(path, content) }
155
+ systemctl! "daemon-reload"
156
+ return :stopped if stopped
157
+
158
+ systemctl! "enable", timer_unit
159
+ systemctl! "restart", timer_unit # picks up a changed schedule
160
+ :active
161
+ end
162
+
163
+ def uninstall(name)
164
+ service_unit = service(name)
165
+ systemctl "disable", "--now", timer(name)
166
+ systemctl "stop", service_unit
167
+ FileUtils.rm_f [service_path(name), timer_path(name)]
168
+ systemctl "daemon-reload"
169
+ systemctl "reset-failed", service_unit
170
+ end
171
+
172
+ def start(name) = systemctl!("enable", "--now", timer(name))
173
+
174
+ def stop(name) = systemctl!("disable", "--now", timer(name))
175
+
176
+ def trigger(name) = systemctl!("start", "--no-block", service(name))
177
+
178
+ def installed_names
179
+ prefix = self.class.unit_prefix(@project)
180
+ Dir.glob(timer_path("*")).map { |path| File.basename(path, ".timer").delete_prefix(prefix) }.sort
181
+ end
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
+
206
+ def status(name)
207
+ _, active = systemctl("is-active", "--quiet", timer(name))
208
+ state = if active then :active
209
+ elsif enabled?(name) then :not_loaded
210
+ else :stopped
211
+ end
212
+ out, = systemctl("show", service(name), "-p", "ExecMainStatus", "-p", "ExecMainExitTimestampMonotonic")
213
+ props = self.class.parse_show(out)
214
+ stamp = props["ExecMainExitTimestampMonotonic"].to_s
215
+ ran = !stamp.empty? && stamp != "0"
216
+ { state:, last_exit: ran ? props["ExecMainStatus"] : nil }
217
+ end
218
+
219
+ def notes
220
+ out, ok = run("loginctl", "show-user", @user.to_s, "-p", "Linger", "--value")
221
+ return [] unless ok && out.strip == "no"
222
+
223
+ ["systemd: user timers only run while #{@user} is logged in; run `loginctl enable-linger` to keep them running."]
224
+ end
225
+
226
+ private
227
+
228
+ def timer(name) = "#{unit(name)}.timer"
229
+
230
+ def service(name) = "#{unit(name)}.service"
231
+
232
+ def enabled?(name) = systemctl("is-enabled", "--quiet", timer(name)).last
233
+
234
+ def systemctl(*) = run("systemctl", "--user", *)
235
+
236
+ def systemctl!(*args)
237
+ out, ok = systemctl(*args)
238
+ raise Error, "systemctl --user #{args.join(' ')} failed: #{out.strip}" unless ok
239
+
240
+ out
241
+ end
242
+
243
+ def run(*argv) = @runner.call(*argv)
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,78 @@
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.
27
+ def self.draw(rows, title: nil, io: $stdout)
28
+ table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io)).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:)
34
+ require "pastel"
35
+
36
+ @cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
37
+ @pastel = Pastel.new(enabled: color)
38
+ @width = width
39
+ @column_widths = natural_widths
40
+ shrink_widest while overflowing? && shrinkable?
41
+ end
42
+
43
+ def render
44
+ require "tty-table"
45
+
46
+ grid = TTY::Table.new(header: HEADERS.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
47
+ grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
48
+ end
49
+
50
+ # Width of each column, padding included. Every column starts at its
51
+ # natural width; when the table would be wider than the screen, the
52
+ # flexible columns give up a character at a time until it fits.
53
+ attr_reader :column_widths
54
+
55
+ # Rendered width: the columns plus their borders.
56
+ def total_width = column_widths.sum + BORDERS
57
+
58
+ private
59
+
60
+ def natural_widths = [HEADERS, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
61
+
62
+ def overflowing? = total_width > @width
63
+
64
+ def widest_flexible = FLEXIBLE.max_by { |index| @column_widths[index] }
65
+
66
+ def shrinkable? = @column_widths[widest_flexible] > MIN_WIDTH
67
+
68
+ def shrink_widest = @column_widths[widest_flexible] -= 1
69
+
70
+ def styled(cells)
71
+ *lead, state, last_exit = cells
72
+ state_color = { "active" => :green, "stopped" => :yellow }.fetch(state, :red)
73
+ exit_color = last_exit.match?(/\A[1-9]\d*\z/) ? :red : :dim
74
+ [*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schedule/declaration"
4
+ require_relative "schedule/launchd"
5
+ require_relative "schedule/systemd"
6
+ require_relative "schedule/table"
7
+ require_relative "schedule/commands"
8
+
9
+ module Asgard
10
+ # Runs asgard tasks periodically under the platform's own scheduler.
11
+ # Declare entries at class level in a .loki file:
12
+ #
13
+ # class Tasks
14
+ # schedule :daily_summary, at: "17:30", on: :weekdays
15
+ # schedule :sync, every: 3600
16
+ # schedule :report, options: "--period week", at: "16:00", on: :friday
17
+ # end
18
+ #
19
+ # then manage them with `asgard schedule install|list|start|stop|...`.
20
+ module Schedule
21
+ class << self
22
+ # Declared entries for this run, keyed by entry name.
23
+ def declarations
24
+ @declarations ||= {}
25
+ end
26
+
27
+ # Records one declaration. Redeclaring an identical entry is a no-op
28
+ # (a .loki file loaded twice); a different entry under the same name
29
+ # is an error.
30
+ def declare(task, **settings)
31
+ spec = normalize(task, **settings)
32
+ name = spec[:name]
33
+ taken = declarations[name]
34
+ raise ArgumentError, "schedule: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
35
+
36
+ declarations[name] = spec
37
+ end
38
+ end
39
+
40
+ # The class-level `schedule` declaration helper for Tasks.
41
+ module DSL
42
+ def schedule(task, **) = Schedule.declare(task, **)
43
+ end
44
+ end
45
+ end
data/lib/asgard/tasks.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "schedule"
4
+
3
5
  # Tasks is the single conventional entry point for all .loki files.
4
6
  # It is pre-defined by the gem so .loki files never need to declare a class.
5
7
  # Auxiliary *.loki files define modules which are imported into Tasks.
@@ -32,4 +34,14 @@ class Tasks < Asgard::Base
32
34
  default: false,
33
35
  desc: "Diagnose .loki resolution, imports, and task definitions for the CWD, then exit"
34
36
  no_negate :doctor
37
+
38
+ # Class-level `schedule :task, at: "HH:MM"` declarations (see Asgard::Schedule).
39
+ extend Asgard::Schedule::DSL
40
+
41
+ # Gem-owned, so the command is _schedule; the map keeps `asgard schedule`
42
+ # as the name users type, and ancestor_name keeps it in subcommand help.
43
+ desc "schedule SUBCOMMAND", "Manage schedules declared with `schedule :task, ...` (launchd on macOS, systemd on Linux)"
44
+ subcommand "_schedule", Asgard::Schedule::Commands
45
+ map "schedule" => :_schedule
46
+ Asgard::Schedule::Commands.commands.each_value { |command| command.ancestor_name = "schedule" }
35
47
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "asgard/errors"
3
4
  require_relative "asgard/version"
4
5
  require_relative "asgard/kernel_methods"
5
6
  require_relative "asgard/shell"
@@ -8,15 +9,16 @@ require_relative "asgard/tasks"
8
9
  require_relative "asgard/doctor"
9
10
 
10
11
  module Asgard
11
- class Error < StandardError; end
12
- class CircularDependencyError < Error; end
13
-
14
12
  # Search the current directory and its ancestors for a .loki task file.
15
13
  # Returns the path string, or nil if not found.
16
14
  def self.find_task_file
17
15
  loki_up
18
16
  end
19
17
 
18
+ # `asgard schedule list` reports every project's scheduled entries, so it
19
+ # works from a directory with no .loki above it.
20
+ def self.machine_wide?(argv) = argv.first(2) == %w[schedule list]
21
+
20
22
  # Main entry point invoked by the asgard executable.
21
23
  def self.run!(argv)
22
24
  first = argv.first
@@ -29,13 +31,19 @@ module Asgard
29
31
  Asgard::Doctor.new.run
30
32
  exit
31
33
  end
32
- task_file = find_task_file or abort "asgard: no .loki file found in #{Dir.pwd}"
34
+ task_file = find_task_file
35
+ abort "asgard: no .loki file found in #{Dir.pwd}" unless task_file || machine_wide?(argv)
33
36
  before = Asgard::Base.subclasses.dup
34
- load task_file
37
+ load task_file if task_file
35
38
  newly_defined = Asgard::Base.subclasses - before
36
39
  (newly_defined + [Tasks]).uniq.each(&:validate_deps!)
37
40
  Tasks._reset_ran!
38
- Tasks.start(argv)
41
+ result = Tasks.start(argv)
42
+ # Quality-gate convention: a task signals failure by returning :fail
43
+ # (see dev/quality.loki's *_check tasks). Surface that as a nonzero
44
+ # exit so callers (CI, cross-repo runners) can rely on $?.
45
+ exit(1) if result == :fail
46
+ result
39
47
  rescue CircularDependencyError => e
40
48
  abort "asgard: circular dependency — #{e.message}"
41
49
  rescue Error => e