kanseishitsu 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/README.md +141 -0
- data/bin/cron.rb +27 -0
- data/bin/watch.rb +27 -0
- data/lib/kanseishitsu/cron/argument_parser.rb +120 -0
- data/lib/kanseishitsu/cron/launch_agent_manager_rexml.rb +183 -0
- data/lib/kanseishitsu/cron.rb +53 -0
- data/lib/kanseishitsu/launch_agent_constants.rb +41 -0
- data/lib/kanseishitsu/launch_agent_manager.rb +224 -0
- data/lib/kanseishitsu/logging.rb +49 -0
- data/lib/kanseishitsu/plist_parser.rb +110 -0
- data/lib/kanseishitsu/version.rb +11 -0
- data/lib/kanseishitsu/watch/argument_parser.rb +138 -0
- data/lib/kanseishitsu/watch.rb +90 -0
- data/lib/kanseishitsu/which.rb +38 -0
- data/lib/kanseishitsu/xml_helper.rb +53 -0
- data/lib/kanseishitsu/xml_helper_rexml.rb +56 -0
- data/lib/kanseishitsu.rb +10 -0
- metadata +96 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 25050a62712f4707b8f1b388647b618d7d722fc15f9a7713a6a2b06d504236d6
|
4
|
+
data.tar.gz: adf5c5eba9308caeb08cec65eefa966caf9f9ab6784eeef243d50030be5a0f79
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1ec5b00e46746dbf649876030ca6b7825471c2e4d22ea1f8ba5af2ca7adbdc03328ae61be34b74f81537c99aa4cc75be9dcd60425f44a60584ec433121c6f8a2
|
7
|
+
data.tar.gz: 6f89365ab7c0d2cc4d724ec71fae360cc66d70bb94492b209b66dba2cb23f470d22fcb634b7eb1776190774c8aa36ea0131304c9060c35a7e569a69a54f80eda
|
data/LICENSE.md
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Nels Nelson
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
# kanseishitsu
|
2
|
+
|
3
|
+
A user who wishes to define their own cron jobs and file monitors in macOS faces some obstacles.
|
4
|
+
|
5
|
+
As of 2024, the current state of the art relies on XML plist files to define jobs in the `Library/LaunchAgents` directory in a user's home directory.
|
6
|
+
|
7
|
+
XML is not a very simple format for a human being to read or write.
|
8
|
+
|
9
|
+
If you already have the skills to read and write macOS LaunchAgent XML documents with ease, then the authors commend you for your determination and studiousness.
|
10
|
+
|
11
|
+
Otherwise, for all of you lazy and ignorant people like the authors, this gem is for you!
|
12
|
+
|
13
|
+
The `kanseishitsu` gem provides some little ruby scripts which will support simpler management of such launch agents.
|
14
|
+
|
15
|
+
|
16
|
+
## Usage
|
17
|
+
|
18
|
+
Here is some usage and examples for each script.
|
19
|
+
|
20
|
+
|
21
|
+
### cron.rb
|
22
|
+
|
23
|
+
The usage for `cron.rb`.
|
24
|
+
|
25
|
+
The `cron.rb` executable uses something similar to a `crontab` entry in GNU/Linux.
|
26
|
+
|
27
|
+
```sh
|
28
|
+
$ cron.rb --help
|
29
|
+
Usage: cron.rb <crontab>|<options>
|
30
|
+
|
31
|
+
Options:
|
32
|
+
--show-all Show all cron jobs
|
33
|
+
-l, --list List launch agent labels
|
34
|
+
-r, --remove=<label> Remove a cron job by label
|
35
|
+
```
|
36
|
+
|
37
|
+
|
38
|
+
#### Example
|
39
|
+
|
40
|
+
For example:
|
41
|
+
|
42
|
+
```sh
|
43
|
+
cron.rb "0 1 * * * archive.rb ~/Downloads/archive"
|
44
|
+
```
|
45
|
+
|
46
|
+
|
47
|
+
### watch.rb
|
48
|
+
|
49
|
+
The usage for `watch.rb`.
|
50
|
+
|
51
|
+
```sh
|
52
|
+
$ watch.rb --help
|
53
|
+
Usage: watch.rb <watch_path> <handler_program_path>
|
54
|
+
|
55
|
+
Options:
|
56
|
+
--show-all Show watchers
|
57
|
+
-l, --list List launch agent labels
|
58
|
+
-r, --remove=<label> Remove a watcher by label
|
59
|
+
```
|
60
|
+
|
61
|
+
|
62
|
+
#### Example
|
63
|
+
|
64
|
+
For example:
|
65
|
+
|
66
|
+
```sh
|
67
|
+
watch.rb ~/Desktop ~/.local/usr/bin/desktop.rb
|
68
|
+
```
|
69
|
+
|
70
|
+
|
71
|
+
## Docker
|
72
|
+
|
73
|
+
Build the docker image to support CI/CD containers.
|
74
|
+
|
75
|
+
```sh
|
76
|
+
docker buildx build --tag="$(basename $(pwd))" .
|
77
|
+
docker run --interactive --tty --rm --name "$(basename $(pwd))" "$(basename $(pwd))"
|
78
|
+
```
|
79
|
+
|
80
|
+
|
81
|
+
## Name meaning that nobody should really care about
|
82
|
+
|
83
|
+
The Japanese word "管制室" (Kanseishitsu) translates to "control room" in English. It refers to a room from which operations are directed or monitored, typically involving equipment and personnel to oversee and manage various functions such as in factories, airports, or television studios.
|
84
|
+
|
85
|
+
Pronounced: Kahn-say-she-tsu
|
86
|
+
|
87
|
+
Here's how to say each part:
|
88
|
+
|
89
|
+
- Kahn: like the beginning of "con" in "convenient," but with a more open 'a' sound.
|
90
|
+
- Say: just like the English word "say."
|
91
|
+
- She: as in "she."
|
92
|
+
- Tsu: this part can be a bit tricky because it's not a sound typically found in English. It's similar to saying "sue," but start it with a soft 't' sound placed right before the 's'.
|
93
|
+
|
94
|
+
|
95
|
+
## Project file tree
|
96
|
+
|
97
|
+
Here is a bird's-eye view of the project layout.
|
98
|
+
|
99
|
+
```sh
|
100
|
+
date && tree -A -I "Gemfile.lock|*.gem|tmp|vendor"
|
101
|
+
Wed May 15 23:07:43 CDT 2024
|
102
|
+
.
|
103
|
+
├── Dockerfile
|
104
|
+
├── Gemfile
|
105
|
+
├── LICENSE.md
|
106
|
+
├── README.md
|
107
|
+
├── Rakefile
|
108
|
+
├── bin
|
109
|
+
│ ├── cron.rb
|
110
|
+
│ └── watch.rb
|
111
|
+
├── kanseishitsu.gemspec
|
112
|
+
├── kanseishitsu.png
|
113
|
+
├── lib
|
114
|
+
│ ├── kanseishitsu
|
115
|
+
│ │ ├── cron
|
116
|
+
│ │ │ ├── argument_parser.rb
|
117
|
+
│ │ │ └── launch_agent_manager_rexml.rb
|
118
|
+
│ │ ├── cron.rb
|
119
|
+
│ │ ├── launch_agent_constants.rb
|
120
|
+
│ │ ├── launch_agent_manager.rb
|
121
|
+
│ │ ├── logging.rb
|
122
|
+
│ │ ├── plist_parser.rb
|
123
|
+
│ │ ├── version.rb
|
124
|
+
│ │ ├── watch
|
125
|
+
│ │ │ └── argument_parser.rb
|
126
|
+
│ │ ├── watch.rb
|
127
|
+
│ │ ├── which.rb
|
128
|
+
│ │ ├── xml_helper.rb
|
129
|
+
│ │ └── xml_helper_rexml.rb
|
130
|
+
│ └── kanseishitsu.rb
|
131
|
+
├── scripts
|
132
|
+
│ └── version_bump.sh
|
133
|
+
└── spec
|
134
|
+
├── spec_helper.rb
|
135
|
+
├── test_spec.rb
|
136
|
+
└── verify
|
137
|
+
├── launchctl.rb
|
138
|
+
└── verify_spec.rb
|
139
|
+
|
140
|
+
8 directories, 28 files
|
141
|
+
```
|
data/bin/cron.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#! /usr/bin/env ruby
|
2
|
+
|
3
|
+
# encoding: utf-8
|
4
|
+
# frozen_string_literal: false
|
5
|
+
|
6
|
+
# -*- mode: ruby -*-
|
7
|
+
# vi: set ft=ruby :
|
8
|
+
|
9
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
10
|
+
|
11
|
+
# Usage: cron.rb <crontab>|<options>
|
12
|
+
#
|
13
|
+
# Options:
|
14
|
+
# --show-all Show cron jobs
|
15
|
+
# -l, --list List cron jobs labels
|
16
|
+
# -r, --remove=<label> Remove a cron job by label
|
17
|
+
#
|
18
|
+
# Examples:
|
19
|
+
#
|
20
|
+
# cron.rb "0 1 * * * archive.rb ${HOME}/Downloads/archive"
|
21
|
+
# cron.rb --show-all
|
22
|
+
# cron.rb --list
|
23
|
+
# cron.rb --remove=com.local.archive
|
24
|
+
|
25
|
+
require_relative '../lib/kanseishitsu'
|
26
|
+
|
27
|
+
Object.new.extend(Cron).main
|
data/bin/watch.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#! /usr/bin/env ruby
|
2
|
+
|
3
|
+
# encoding: utf-8
|
4
|
+
# frozen_string_literal: false
|
5
|
+
|
6
|
+
# -*- mode: ruby -*-
|
7
|
+
# vi: set ft=ruby :
|
8
|
+
|
9
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
10
|
+
|
11
|
+
# Usage: watch.rb <path> [handler_program_path]
|
12
|
+
#
|
13
|
+
# Options:
|
14
|
+
# --show-all Show watchers
|
15
|
+
# -l, --list List launch agent labels
|
16
|
+
# -r, --remove=<label> Remove a watcher by label
|
17
|
+
#
|
18
|
+
# Examples:
|
19
|
+
#
|
20
|
+
# watch.rb ~/Desktop ~/.local/usr/bin/desktop.rb
|
21
|
+
# watch.rb --show-all
|
22
|
+
# watch.rb --list
|
23
|
+
# watch.rb --remove=com.local.desktop
|
24
|
+
|
25
|
+
require_relative '../lib/kanseishitsu'
|
26
|
+
|
27
|
+
Object.new.extend(Watcher).main
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: false
|
3
|
+
|
4
|
+
# vi: set ft=ruby :
|
5
|
+
# -*- mode: ruby -*-
|
6
|
+
|
7
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
8
|
+
|
9
|
+
require 'logger'
|
10
|
+
require 'optparse'
|
11
|
+
|
12
|
+
require_relative '../version'
|
13
|
+
|
14
|
+
# Define module Cron
|
15
|
+
module Cron
|
16
|
+
# Define the ArgumentsParser class
|
17
|
+
class ArgumentsParser
|
18
|
+
FLAGS = %i[banner show_all list remove verbose version].freeze
|
19
|
+
POSITIONAL = %i[crontab].freeze
|
20
|
+
attr_reader :parser, :options
|
21
|
+
|
22
|
+
def initialize(option_parser = OptionParser.new)
|
23
|
+
@parser = option_parser
|
24
|
+
@options = {}
|
25
|
+
FLAGS.each { |method_name| self.method(method_name).call }
|
26
|
+
end
|
27
|
+
|
28
|
+
def banner
|
29
|
+
@parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} <crontab>|<options>"
|
30
|
+
@parser.separator ''
|
31
|
+
@parser.separator 'Options:'
|
32
|
+
end
|
33
|
+
|
34
|
+
def show_all
|
35
|
+
@parser.on('--show-all', 'Show cron jobs') do
|
36
|
+
@options[:show_all] = true
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def list
|
41
|
+
@parser.on('-l', '--list', 'List cron jobs labels') do
|
42
|
+
@options[:list] = true
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def remove
|
47
|
+
@parser.on('-r', '--remove=<label>', 'Remove a cron job by label') do |label|
|
48
|
+
@options[:remove] = label
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def verbose
|
53
|
+
@options[:log_level] ||= Logger::INFO
|
54
|
+
@parser.on_tail('-v', '--verbose', 'Increase verbosity') do
|
55
|
+
@options[:log_level] -= 1
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def version
|
60
|
+
@parser.on_tail('--version', 'Show version') do
|
61
|
+
puts "#{File.basename($PROGRAM_NAME)} version #{Kanseishitsu::VERSION}"
|
62
|
+
exit
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def crontab(args)
|
67
|
+
crontab = args.shift.gsub(/\A['"]|['"]\z/, '').split
|
68
|
+
@options[:crontab] = crontab
|
69
|
+
@options[:cron_schedule] =
|
70
|
+
crontab.take(LaunchAgentManager::SCHEDULE_PARTS_COUNT).join(' ')
|
71
|
+
@options[:executable_path_with_args] =
|
72
|
+
crontab.drop(LaunchAgentManager::SCHEDULE_PARTS_COUNT).join(' ')
|
73
|
+
end
|
74
|
+
|
75
|
+
def demand(arg, positional: false)
|
76
|
+
return @options[arg] unless @options[arg].nil?
|
77
|
+
|
78
|
+
required_arg = if positional then "<#{arg}>"
|
79
|
+
else "--#{arg.to_s.gsub(UNDERSCORE_PATTERN, HYPHEN_STRING)}"
|
80
|
+
end
|
81
|
+
raise OptionParser::MissingArgument, "Required argument: #{required_arg}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def positional!(args)
|
85
|
+
POSITIONAL.each do |opt|
|
86
|
+
self.method(opt).call(args)
|
87
|
+
self.demand(opt, positional: true)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def options?
|
92
|
+
ArgumentsParser::FLAGS.any? { |flag| @options.include?(flag) }
|
93
|
+
end
|
94
|
+
|
95
|
+
def usage!
|
96
|
+
puts @parser
|
97
|
+
exit
|
98
|
+
end
|
99
|
+
|
100
|
+
# rubocop: disable Metrics/MethodLength
|
101
|
+
def self.parse(args = ARGV, _file_path = ARGF, arguments_parser = ArgumentsParser.new)
|
102
|
+
arguments_parser.parser.parse!(args)
|
103
|
+
if !arguments_parser.options? && ARGV.length != 1
|
104
|
+
message = 'A crontab definition is required'
|
105
|
+
raise OptionParser::MissingArgument, message
|
106
|
+
elsif !args.empty?
|
107
|
+
arguments_parser.positional!(args)
|
108
|
+
end
|
109
|
+
arguments_parser.options
|
110
|
+
rescue OptionParser::AmbiguousOption => e
|
111
|
+
abort e.message
|
112
|
+
rescue OptionParser::ParseError => e
|
113
|
+
puts e.message
|
114
|
+
arguments_parser.usage!
|
115
|
+
end
|
116
|
+
# rubocop: enable Metrics/MethodLength
|
117
|
+
end
|
118
|
+
# class ArgumentsParser
|
119
|
+
end
|
120
|
+
# module Cron
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: false
|
3
|
+
|
4
|
+
# vi: set ft=ruby :
|
5
|
+
# -*- mode: ruby -*-
|
6
|
+
|
7
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
8
|
+
|
9
|
+
require 'rexml/formatters/pretty'
|
10
|
+
|
11
|
+
require 'fileutils'
|
12
|
+
|
13
|
+
require_relative '../launch_agent_plist'
|
14
|
+
|
15
|
+
# Define module LaunchAgentManagerConstants
|
16
|
+
module LaunchAgentManagerConstants
|
17
|
+
VERSION = '0.1.0'.freeze unless defined?(VERSION)
|
18
|
+
LABEL_NAMESPACE = %w[com local].freeze
|
19
|
+
INTERVALS = %w[Minute Hour Day Month Weekday].freeze
|
20
|
+
SCHEDULE_PARTS_COUNT = INTERVALS.length
|
21
|
+
LAUNCHCTL_TEMPLATE = 'launchctl bootstrap gui/%<uid>s %<plist>s'.freeze
|
22
|
+
SUCCESS_MESSAGE = 'Created and enabled launchd job: %<label>s'.freeze
|
23
|
+
LAUNCH_AGENTS_DIR_PATH = File.expand_path(File.join(Dir.home, 'Library', 'LaunchAgents'))
|
24
|
+
BOOTOUT_TEMPLATE = 'launchctl bootout gui/%<uid>s/%<label>s'.freeze
|
25
|
+
REMOVE_TEMPLATE = 'launchctl remove gui/%<uid>s/%<label>s'.freeze
|
26
|
+
end
|
27
|
+
|
28
|
+
# Define module LaunchAgentManagerInstanceMethods
|
29
|
+
module LaunchAgentManagerInstanceMethods
|
30
|
+
include LaunchAgentManagerConstants
|
31
|
+
|
32
|
+
def find_executable_in_path(cmd)
|
33
|
+
directory = ENV['PATH'].split(File::PATH_SEPARATOR).find do |dir|
|
34
|
+
File.executable?(File.join(dir, cmd))
|
35
|
+
end
|
36
|
+
directory.nil? ? nil : File.join(directory, cmd)
|
37
|
+
end
|
38
|
+
|
39
|
+
def executable?(exe)
|
40
|
+
!exe.empty? && File.executable?(exe) && !File.directory?(exe)
|
41
|
+
end
|
42
|
+
|
43
|
+
def explicit_which(cmd)
|
44
|
+
exe = `which #{cmd}`.chomp
|
45
|
+
executable?(exe) ? exe : nil
|
46
|
+
rescue Errno::ENOENT => _e
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
|
50
|
+
def portable_which(cmd)
|
51
|
+
explicit_which(cmd) || find_executable_in_path(cmd)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Extract program path and arguments, verifying executable
|
55
|
+
# rubocop: disable Metrics/MethodLength
|
56
|
+
def extract_program_path(executable_with_args)
|
57
|
+
*args = executable_with_args.split
|
58
|
+
exe = args.shift
|
59
|
+
exe_path = File.exist?(exe) ? exe : portable_which(exe)
|
60
|
+
if exe_path.nil? || !File.exist?(exe_path)
|
61
|
+
abort "Cannot find executable in path: #{exe}"
|
62
|
+
end
|
63
|
+
if exe_path.nil? || !File.executable?(exe_path) || File.directory?(exe_path)
|
64
|
+
abort "Given file is not executable: #{exe}"
|
65
|
+
end
|
66
|
+
args.unshift(exe_path)
|
67
|
+
args
|
68
|
+
end
|
69
|
+
# rubocop: enable Metrics/MethodLength
|
70
|
+
|
71
|
+
# Create the job name based on the given executable file path
|
72
|
+
def derive_job_label_from_executable(exe_path)
|
73
|
+
label = LABEL_NAMESPACE.dup
|
74
|
+
label << File.basename(exe_path, '.*')
|
75
|
+
label.join('.')
|
76
|
+
end
|
77
|
+
|
78
|
+
# Define cron settings for plist
|
79
|
+
def derive_calendar_interval(cron_schedule)
|
80
|
+
cron_fields = cron_schedule.split
|
81
|
+
calendar_interval = {}
|
82
|
+
INTERVALS.each do |key|
|
83
|
+
value = cron_fields.shift
|
84
|
+
raise "Invalid cron string: #{cron_schedule}" if value.nil? || value.empty?
|
85
|
+
|
86
|
+
calendar_interval[key] = value
|
87
|
+
end
|
88
|
+
calendar_interval
|
89
|
+
end
|
90
|
+
|
91
|
+
# Save plist file to LaunchAgents directory; do not overwrite an
|
92
|
+
# already existing file with the same name at the generated path
|
93
|
+
# rubocop: disable Metrics/MethodLength
|
94
|
+
def save_plist(label, doc)
|
95
|
+
FileUtils.mkdir_p(LAUNCH_AGENTS_DIR_PATH)
|
96
|
+
plist_path = File.join(LAUNCH_AGENTS_DIR_PATH, "#{label}.plist")
|
97
|
+
puts "plist_path: #{plist_path}"
|
98
|
+
return plist_path if File.exist?(plist_path)
|
99
|
+
|
100
|
+
document = ''
|
101
|
+
formatter = REXML::Formatters::Pretty.new(4)
|
102
|
+
formatter.write(doc, document)
|
103
|
+
puts '[DEBUG] Contents of plist xml document:'
|
104
|
+
puts document
|
105
|
+
File.write(plist_path, document)
|
106
|
+
plist_path
|
107
|
+
end
|
108
|
+
# rubocop: enable Metrics/MethodLength
|
109
|
+
|
110
|
+
def execute(command)
|
111
|
+
puts "[DEBUG] Executing command: #{command}"
|
112
|
+
system(command)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Load the launchd job
|
116
|
+
def load_launchd_job(plist_path)
|
117
|
+
execute(format(LAUNCHCTL_TEMPLATE, uid: Process.uid, plist: plist_path))
|
118
|
+
end
|
119
|
+
|
120
|
+
# Show the launchd agents as crontabs
|
121
|
+
def show_all_cron_jobs
|
122
|
+
Dir.glob(File.join(LAUNCH_AGENTS_DIR_PATH, '*.plist')).map do |plist_path|
|
123
|
+
job = parse_plist(plist_path)
|
124
|
+
puts "#{job[:schedule]} #{job[:command]}"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Return a list of all user launchd agent labels
|
129
|
+
def launch_agent_labels
|
130
|
+
plist_file_paths = Dir.glob(File.join(LAUNCH_AGENTS_DIR_PATH, '*.plist'))
|
131
|
+
plist_file_paths.each_with_object([]) do |file_path, labels|
|
132
|
+
label = LaunchAgentPlist.parse(file_path)[:label]
|
133
|
+
labels << label unless label.nil?
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# List labels of launchd agents
|
138
|
+
def list_launch_agent_labels
|
139
|
+
launch_agent_labels.each do |label|
|
140
|
+
puts label
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Remove plist Launch Agents by label
|
145
|
+
def remove_cron_job(label)
|
146
|
+
# warn "[DEBUG] Removing launch agent: #{label}"
|
147
|
+
plist_path = File.expand_path(File.join(LAUNCH_AGENTS_DIR_PATH, "#{label}.plist"))
|
148
|
+
# warn "[DEBUG] Removing launch agent plist definition file: #{plist_path}"
|
149
|
+
if File.exist?(plist_path)
|
150
|
+
execute(format(BOOTOUT_TEMPLATE, uid: Process.uid, label: label))
|
151
|
+
execute(format(REMOVE_TEMPLATE, uid: Process.uid, label: label))
|
152
|
+
FileUtils.rm(plist_path)
|
153
|
+
puts "Removed launch agent: #{label}"
|
154
|
+
else
|
155
|
+
warn "Not found; crontab or launch agent: #{label}"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Create the launchd job
|
160
|
+
def create_launchd_job(options)
|
161
|
+
args = extract_program_path(options[:executable_path_with_args])
|
162
|
+
label = derive_job_label_from_executable(args.first)
|
163
|
+
calendar_interval = derive_calendar_interval(options[:cron_schedule])
|
164
|
+
plist_doc = LaunchAgentPlist.new(label, args, calendar_interval).to_doc
|
165
|
+
plist_path = save_plist(label, plist_doc)
|
166
|
+
puts "Executing: cat #{plist_path}"
|
167
|
+
puts `cat #{plist_path}`.strip
|
168
|
+
load_launchd_job(plist_path)
|
169
|
+
puts format(SUCCESS_MESSAGE, label: label)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
# module LaunchAgentManagerInstanceMethods
|
173
|
+
|
174
|
+
# Define the LaunchAgentManager class
|
175
|
+
class LaunchAgentManager
|
176
|
+
include LaunchAgentManagerInstanceMethods
|
177
|
+
|
178
|
+
attr_reader :options
|
179
|
+
|
180
|
+
def initialize(options)
|
181
|
+
@options = options
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
#! /usr/bin/env ruby
|
2
|
+
# encoding: utf-8
|
3
|
+
# frozen_string_literal: false
|
4
|
+
|
5
|
+
# vi: set ft=ruby :
|
6
|
+
# -*- mode: ruby -*-
|
7
|
+
|
8
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
9
|
+
|
10
|
+
# Usage: cron.rb <crontab>|<options>
|
11
|
+
#
|
12
|
+
# Options:
|
13
|
+
# --show-all Show cron jobs
|
14
|
+
# -l, --list List cron jobs labels
|
15
|
+
# -r, --remove=<label> Remove a cron job by label
|
16
|
+
#
|
17
|
+
# Examples:
|
18
|
+
#
|
19
|
+
# cron.rb "0 1 * * * archive.rb ${HOME}/Downloads/archive"
|
20
|
+
# cron.rb --show-all
|
21
|
+
# cron.rb --list
|
22
|
+
# cron.rb --remove=com.local.archive
|
23
|
+
|
24
|
+
require_relative 'launch_agent_manager'
|
25
|
+
require_relative 'cron/argument_parser'
|
26
|
+
|
27
|
+
# Define module Cron
|
28
|
+
module Cron
|
29
|
+
LaunchAgentManager = ::LaunchAgentManager
|
30
|
+
|
31
|
+
# rubocop: disable Metrics/MethodLength
|
32
|
+
def main(args = Cron::ArgumentsParser.parse)
|
33
|
+
log.log_level = args[:log_level]
|
34
|
+
manager = Cron::LaunchAgentManager.new(args)
|
35
|
+
if args[:show_all]
|
36
|
+
manager.show_all_launch_agents
|
37
|
+
elsif args[:list]
|
38
|
+
manager.list_launch_agent_labels
|
39
|
+
elsif args[:remove]
|
40
|
+
manager.remove_launch_agent(args[:remove])
|
41
|
+
else
|
42
|
+
manager.create_launch_agent(
|
43
|
+
args[:executable_path_with_args],
|
44
|
+
nil,
|
45
|
+
args[:cron_schedule]
|
46
|
+
)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
# rubocop: enable Metrics/MethodLength
|
50
|
+
end
|
51
|
+
# module Cron
|
52
|
+
|
53
|
+
Object.new.extend(Cron).main if $PROGRAM_NAME == __FILE__
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: false
|
3
|
+
|
4
|
+
# -*- mode: ruby -*-
|
5
|
+
# vi: set ft=ruby :
|
6
|
+
|
7
|
+
# Copyright Nels Nelson 2024 but freely usable (see license)
|
8
|
+
|
9
|
+
# Define the LaunchAgentConstants module
|
10
|
+
module LaunchAgentConstants
|
11
|
+
DEFAULT_ENCODING = 'UTF-8'.freeze
|
12
|
+
DOCTYPE_ELEMENT = '<!%<doctypes>s>'.freeze
|
13
|
+
DOCTYPES = [
|
14
|
+
'DOCTYPE',
|
15
|
+
'plist',
|
16
|
+
'PUBLIC',
|
17
|
+
'"-//Apple//DTD PLIST 1.0//EN"',
|
18
|
+
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd"'
|
19
|
+
].freeze
|
20
|
+
LABEL_NAMESPACE = %w[com local].freeze
|
21
|
+
INTERVALS = %w[Minute Hour Day Month Weekday].freeze
|
22
|
+
SCHEDULE_PARTS_COUNT = INTERVALS.length
|
23
|
+
CDATA_PATTERN = %r{[<>&'"\n]+}
|
24
|
+
SCHEDULE_XPATH_TEMPLATE = './/key[text()="%<key>s"]/following-sibling::integer'.freeze
|
25
|
+
KEYS_REQUIRING_CDATA = ['PATH'].freeze
|
26
|
+
LAUNCHCTL_TEMPLATE = 'launchctl bootstrap gui/%<uid>s %<plist>s'.freeze
|
27
|
+
SUCCESS_MESSAGE = 'Created and enabled launchd job: %<label>s'.freeze
|
28
|
+
LAUNCH_AGENTS_DIR_PATH = File.expand_path(File.join(Dir.home, 'Library', 'LaunchAgents'))
|
29
|
+
LABEL_XPATH = '//key[text()="Label"]/following-sibling::*[1]'.freeze
|
30
|
+
START_CALENDAR_INTERVAL_XPATH =
|
31
|
+
'//key[text()="StartCalendarInterval"]/following-sibling::*[1]'.freeze
|
32
|
+
WATCH_PATHS_XPATH = '//key[text()="WatchPaths"]/following-sibling::*[1]'.freeze
|
33
|
+
LOCAL_STRING_XPATH = './string'.freeze
|
34
|
+
PROGRAM_ARGUMENTS_XPATH =
|
35
|
+
'//key[text()="ProgramArguments"]/following-sibling::array[1]'.freeze
|
36
|
+
INTERVAL_XPATH_TEMPLATE = './key[text()="%<key>s"]/following-sibling::*[1]'.freeze
|
37
|
+
BOOTOUT_TEMPLATE = 'launchctl bootout gui/%<uid>s/%<label>s'.freeze
|
38
|
+
REMOVE_TEMPLATE = 'launchctl remove gui/%<uid>s/%<label>s'.freeze
|
39
|
+
ON_LOGIN = '@login'.freeze
|
40
|
+
end
|
41
|
+
# module LaunchAgentConstants
|