kanseishitsu 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|